feat: implement Who Need Help MVP

This commit is contained in:
SimpleTest 2026-07-18 16:52:16 +03:00
commit 7f56484527
186 changed files with 16045 additions and 0 deletions

59
.dockerignore Normal file
View File

@ -0,0 +1,59 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
# https://phoenix.hexdocs.pm/Mix.Tasks.Phx.Gen.Release.html#module-docker
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json
/android/.gradle/
/android/app/build/
/android/build/
/android/dist/
/android/dist-*/
/.tools/
/.playwright-cli/
/output/
# Local configuration can contain deployment credentials and must not be sent
# to Docker when Compose builds from the repository root.
/.env
/.env.*
!/.env.example

19
.env.example Normal file
View File

@ -0,0 +1,19 @@
# Local Compose defaults work without this file. Replace every value before a public deployment.
HTTP_PORT=4010
MAILPIT_PORT=8027
PHX_HOST=localhost
# Android debug builds compile this origin into BuildConfig. The Docker
# emulator uses adb reverse to expose the local Compose proxy on loopback.
WNH_DEBUG_BASE_URL=http://localhost:4010
# Public raster tile template used by MapLibre. Use a provider whose policy and
# capacity match the deployment before a public launch.
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# Compose derives PHX_URL_PORT from HTTP_PORT and uses HTTP for local development.
POOL_SIZE=10
SECRET_KEY_BASE=generate-with-mix-phx-gen-secret
HANDOVER_SECRET=generate-an-independent-random-secret
RELEASE_COOKIE=generate-an-independent-beam-cluster-cookie
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}}
RATE_LIMIT_POLICIES_JSON={}

6
.formatter.exs Normal file
View File

@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
* text=auto
*.bat text eol=crlf
*.sh text eol=lf

62
.gitignore vendored Normal file
View File

@ -0,0 +1,62 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
who_need_help-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
/.tools/
# Local environment files can contain deployment credentials. Keep only the
# documented template in Git.
/.env
/.env.*
!/.env.example
# Local browser automation state and generated verification artifacts.
/.playwright-cli/
/output/
# Android build output and local SDK configuration.
/android/.gradle/
/android/.kotlin/
/android/.idea/
/android/.cxx/
/android/app/build/
/android/build/
/android/dist/
/android/dist-*/
/android/local.properties
*.apk
*.aab
*.jks
*.keystore

145
Dockerfile Normal file
View File

@ -0,0 +1,145 @@
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the builder image
# E.g.: docker.io/hexpm/elixir:1.18.4-erlang-27.3.4.13-debian-trixie-20260623-slim
# - https://hub.docker.com/_/debian/tags?name=trixie-20260623-slim - for the runner image
# E.g.: docker.io/debian:trixie-20260623-slim
#
# Find builder and runner images on Docker Hub or on Hex's Build Server (Bob).
# We recommend using Bob's Web UI to find recent tags:
#
# - https://bob.hex.pm/docker
#
# We suggest using the same Debian version for both the builder and runner images.
#
# We suggest Debian/Ubuntu instead of Alpine to avoid production compatibility issues
# (such as DNS resolution failures, and dynamically linked NIFs/precompiled binaries).
#
# For finding packages in Debian, search on https://packages.debian.org/.
ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=27.3.4.13
ARG DEBIAN_VERSION=trixie-20260623-slim
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
FROM docker.io/node:22.21.1-bookworm-slim AS node_deps
WORKDIR /assets
COPY assets/package.json assets/package-lock.json ./
RUN npm ci
FROM ${BUILDER_IMAGE} AS builder
# install build dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force \
&& mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
RUN mix assets.setup
COPY priv priv
COPY lib lib
# Compile the release
RUN mix compile
COPY assets assets
COPY --from=node_deps /assets/node_modules assets/node_modules
# compile assets
RUN mix assets.deploy
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/who_need_help ./
USER nobody
# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]
CMD ["/app/bin/server"]
FROM ${BUILDER_IMAGE} AS test
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN mix local.hex --force \
&& mix local.rebar --force
ENV MIX_ENV="test"
COPY mix.exs mix.lock ./
RUN mix deps.get --only test
COPY config config
RUN mix deps.compile
COPY .formatter.exs ./
COPY priv priv
COPY lib lib
COPY test test
RUN mix compile
CMD ["mix", "test"]
# Keep the production release as the default build result while exposing the
# dedicated `test` target to scripts/test.sh.
FROM final AS release

178
README.md Normal file
View File

@ -0,0 +1,178 @@
# Who Need Help
Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help.
The first supported scenario is pickup and delivery of a legal medicine that
has already been purchased or reserved.
It is not an emergency or medical service, does not prescribe or sell medicine,
and does not process payments. A helper may publish an optional external
thank-you link; money goes directly between users outside the platform.
## What is implemented
- Phoenix 1.8 LiveView application with email magic-link/password auth and 18+
self-attestation.
- Data-driven, translated category tree with validated per-category fields,
community proposals/votes, and human approve/reject/merge tools.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
PubSub/Presence, and optional foreground-only location sharing.
- Handover code plus both-party confirmation before verified completion.
- Double-blind reviews, public trust summaries, and a helper leaderboard that
prioritizes unique location-supported and handover-verified counterparts
before raw totals.
- Bidirectional discovery blocks, scoped reports, account/request/category
moderation, abuse-signal review, and audited moderator access to only the
conversation linked by a report.
- Optional GPS evidence derived from browser accuracy envelopes. Raw current
positions are deleted on stop, terminal match state, or participant block.
- PostgreSQL-backed cross-replica action-limit policies configured by the
operator. No unapproved numeric thresholds are enabled by default.
- Public manual social links, always marked unverified in this MVP.
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
- Native Android WebView client with the same authenticated LiveView, map,
private chat, and foreground geolocation flows. The reproducible Docker
target currently exports a debug APK; production signing and store
publication are not configured.
- Local, advisory Codex category review through the user's ChatGPT-authenticated
Codex CLI. It receives a PII-free export and never writes to the database.
- One immutable release image with `web`, `worker`, and one-shot `migrate`
roles.
- Docker Compose and Helm/kind deployment paths with 2 web and 2 worker
replicas by default.
OAuth social verification, public coffee/cinema/hiking activities, background
tracking, platform payments, production Android signing/store publication,
iOS, automatic punitive fraud decisions, and jurisdiction-specific
public-launch policies are deliberately not claimed as complete.
## Fast start with Docker Compose
Prerequisite: Docker with the Compose plugin.
```bash
./scripts/compose-up.sh
```
Open:
- app: <http://localhost:4010>
- local email inbox: <http://localhost:8027>
Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas,
and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message
broadcast from a different BEAM node. Registration emails appear in Mailpit.
Inspect the exact state:
```bash
docker compose -p who_need_help ps -a
docker compose -p who_need_help logs -f web worker
./scripts/verify-realtime-cluster.sh compose
```
Local defaults are intentionally limited to local development. Copy
`.env.example` to `.env` and replace every secret before any public deployment.
Generate independent values with `mix phx.gen.secret`; do not reuse the session
secret as the BEAM cookie or handover secret. External Helm deployments must
set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email
links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
fit the deployment.
## Tests
The reproducible test command builds a dedicated test target and uses the
project's PostGIS service:
```bash
./scripts/test.sh
```
It creates/updates only the project-scoped `who_need_help_test` database.
## First administrator
Register and confirm the first account, then explicitly bootstrap it:
```bash
./scripts/bootstrap-admin.sh you@example.com --confirm
```
This succeeds only while no administrator exists and writes an audit event.
After bootstrap, an administrator can manage roles in `/moderation`; the last
administrator cannot demote themselves. For kind, append `kind`:
```bash
./scripts/bootstrap-admin.sh you@example.com --confirm kind
```
## Optional shared action limits
`RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every
web replica. Its shape is:
```json
{"action_name":{"limit":"POSITIVE_INTEGER","window_seconds":"POSITIVE_INTEGER"}}
```
The strings above describe the required types and are not a runnable policy.
Keep `{}` until numeric limits have been approved from policy and observed
traffic. Supported actions are listed in `docs/trust-safety.md`.
## Local Kubernetes verification
The kind script downloads checksum-verified kubectl, Helm, and kind binaries
into `.tools/`, creates a project-owned cluster, loads the local image, and
installs the Helm chart, waits for both Deployments, and verifies cross-node
PubSub:
```bash
./scripts/kind-up.sh
```
Open:
- app: <http://localhost:4011>
- Mailpit: <http://localhost:8028>
The script refuses to modify a pre-existing cluster named `who-need-help`
unless the project ownership marker exists.
For an external cluster, provide a real PostgreSQL/PostGIS service and a
pre-created Secret through `existingSecret`. The chart intentionally has no
invented CPU/RAM limits or HPA thresholds; measure this application in the
target environment before setting them.
## Local Codex category review
Export the redacted proposals from a running release:
```bash
docker compose -p who_need_help exec -T web \
/app/bin/who_need_help eval \
'WhoNeedHelp.CatalogModeration.export_open_proposals() |> IO.puts()' \
> proposals.json
```
Then run the advisory review:
```bash
./scripts/codex-review-categories.sh proposals.json recommendations.json
```
The script exits unless `codex login status` is exactly
`Logged in using ChatGPT`. It uses no OpenAI API key, no usage-based API billing,
and no fallback provider. Recommendations require a human moderator action.
## Design documentation
- [Product specification](docs/product-spec.md)
- [Architecture](docs/architecture.md)
- [Trust and safety](docs/trust-safety.md)
- [Implementation verification and known limits](docs/verification.md)
- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md)
Exact dependency versions are locked in `mix.lock`,
`assets/package-lock.json`, the Dockerfile, Compose file, and tool bootstrap
script.

6
android/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.gradle
app/build
build
dist
dist-*
local.properties

109
android/Dockerfile Normal file
View File

@ -0,0 +1,109 @@
FROM gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7 AS android-base
USER root
ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=14742923
ARG ANDROID_COMMAND_LINE_TOOLS_SHA1=48833c34b761c10cb20bcd16582129395d121b27
ENV ANDROID_HOME=/opt/android-sdk
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH="${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl unzip \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl -fsSL \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \
-o /tmp/android-command-line-tools.zip \
&& echo "${ANDROID_COMMAND_LINE_TOOLS_SHA1} /tmp/android-command-line-tools.zip" \
| sha1sum -c - \
&& unzip -q /tmp/android-command-line-tools.zip -d /tmp/android-command-line-tools \
&& mv /tmp/android-command-line-tools/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm -rf /tmp/android-command-line-tools /tmp/android-command-line-tools.zip \
&& yes | sdkmanager --licenses >/dev/null
RUN sdkmanager \
"build-tools;37.0.0" \
"platforms;android-37.0" \
&& chown -R gradle:gradle "${ANDROID_HOME}"
FROM android-base AS android-sdk
USER gradle
WORKDIR /workspace/android
COPY --chown=gradle:gradle . .
ARG WNH_DEBUG_BASE_URL
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
gradle --no-daemon \
"-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \
testDebugUnitTest lintDebug assembleDebug
FROM android-base AS emulator
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libdbus-1-3 \
libfontconfig1 \
libgl1 \
libnss3 \
libpulse0 \
libx11-6 \
libxcomposite1 \
libxcursor1 \
libxi6 \
libxrandr2 \
libxrender1 \
libxtst6 \
&& rm -rf /var/lib/apt/lists/*
RUN sdkmanager \
"emulator" \
"system-images;android-37.0;google_apis_ps16k;x86_64" \
&& chown -R gradle:gradle "${ANDROID_HOME}"
# The container is operated from the host through `docker exec ... adb`.
# Keep platform-tools in the emulator target without invalidating the large
# system-image layer when application sources change.
RUN sdkmanager "platform-tools" \
&& chown -R gradle:gradle "${ANDROID_HOME}"
USER gradle
RUN echo no | avdmanager create avd \
--force \
--name who_need_help_api37 \
--package "system-images;android-37.0;google_apis_ps16k;x86_64"
COPY --from=android-sdk --chown=gradle:gradle \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \
/opt/who-need-help/who-need-help-debug.apk
CMD ["/opt/android-sdk/emulator/emulator", \
"-avd", "who_need_help_api37", \
"-no-window", \
"-no-audio", \
"-no-boot-anim", \
"-no-snapshot", \
"-gpu", "software", \
"-feature", "-Vulkan", \
"-camera-back", "none", \
"-camera-front", "none", \
"-netdelay", "none", \
"-netspeed", "full"]
FROM scratch AS artifact
COPY --from=android-sdk \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \
/who-need-help-debug.apk
COPY --from=android-sdk \
/workspace/android/app/build/reports/lint-results-debug.html \
/lint-results-debug.html

70
android/README.md Normal file
View File

@ -0,0 +1,70 @@
# Who Need Help for Android
This module is a native Android WebView shell for the Phoenix application. It
keeps authentication cookies, LiveView WebSockets, MapLibre, chat, and
foreground geolocation in the same trusted origin.
## Verified build configuration
- Android Gradle Plugin 9.3.0
- Gradle 9.6.1
- compileSdk / targetSdk 37
- Build Tools 37.0.0
- Java source and bytecode level 17
- minSdk 24 (project baseline, not an Android SDK requirement)
The debug origin is not stored in the Dockerfile or Gradle project. Set
`WNH_DEBUG_BASE_URL` in the repository's ignored `.env` file. The supplied
local configuration uses loopback together with `adb reverse`; debug builds
allow cleartext traffic, while the WebView still restricts in-app navigation to
that one configured origin.
Release builds do not have a default server. Supply the real HTTPS deployment:
```sh
./gradlew :app:assembleRelease \
-PWNH_BASE_URL=https://help.your-domain.example
```
The build rejects a missing, HTTP, credentialed, query-bearing, or
fragment-bearing release URL.
## Reproducible Docker build
From the repository root:
```sh
./scripts/android-build.sh
sha256sum android/dist/who-need-help-debug.apk
```
The Docker build runs JVM unit tests, Android lint, and `assembleDebug` before it
exports the APK and lint report.
## Emulator verification
The optional `emulator` target contains the API 37.0 Google APIs x86_64 system
image. It requires KVM and host networking. Pass the same `.env` value as a
build argument, then expose the Compose proxy to Android with `adb reverse`:
```sh
set -a
. ./.env
set +a
docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
--target emulator \
-t who-need-help-android:emulator \
android
docker run --rm --name who-need-help-android-emulator \
--device /dev/kvm \
--network host \
who-need-help-android:emulator
docker exec who-need-help-android-emulator adb reverse tcp:4010 tcp:4010
docker exec who-need-help-android-emulator adb install \
/opt/who-need-help/who-need-help-debug.apk
```
The application ID `org.whoneedhelp.mobile` is provisional until the publishing
identity and store listing are chosen. Changing it after publication creates a
different Android application.

View File

@ -0,0 +1,115 @@
import java.net.URI
import org.gradle.api.tasks.compile.JavaCompile
plugins {
id("com.android.application")
}
val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("")
val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_BASE_URL").orElse("")
android {
namespace = "org.whoneedhelp.mobile"
compileSdk = 37
buildToolsVersion = "37.0.0"
defaultConfig {
applicationId = "org.whoneedhelp.mobile"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
buildConfigField(
"String",
"BASE_URL",
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "true"
}
release {
isMinifyEnabled = true
isShrinkResources = true
buildConfigField(
"String",
"BASE_URL",
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "false"
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.isIncludeAndroidResources = false
}
}
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
doFirst {
val value = releaseBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull()
if (
uri == null ||
uri.scheme != "https" ||
uri.host.isNullOrBlank() ||
uri.userInfo != null ||
uri.query != null ||
uri.fragment != null
) {
throw GradleException(
"Release builds require -PWNH_BASE_URL=https://your-real-deployment.example"
)
}
}
}
tasks.matching { it.name == "preDebugBuild" }.configureEach {
doFirst {
val value = debugBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull()
if (
uri == null ||
(uri.scheme != "http" && uri.scheme != "https") ||
uri.host.isNullOrBlank() ||
uri.userInfo != null ||
uri.query != null ||
uri.fragment != null
) {
throw GradleException(
"Debug builds require -PWNH_DEBUG_BASE_URL=http(s)://your-development-host"
)
}
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-Xlint:deprecation")
}
dependencies {
implementation("androidx.activity:activity:1.13.0")
testImplementation("junit:junit:4.13.2")
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# The app uses only Android framework APIs. Keep rules are intentionally empty.

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Local debug origins may use HTTP; release replaces this resource with
the main configuration, where cleartext traffic is disabled. -->
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration" />
</network-security-config>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.WhoNeedHelp"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,330 @@
package org.whoneedhelp.mobile;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.net.Uri;
import android.net.http.SslError;
import android.os.Bundle;
import android.util.Log;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.GeolocationPermissions;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Toast;
import androidx.activity.ComponentActivity;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
public final class MainActivity extends ComponentActivity {
private static final String LOG_TAG = "WhoNeedHelpWebView";
private WebView webView;
private TrustedOrigin trustedOrigin;
private GeolocationPermissions.Callback pendingLocationCallback;
private String pendingLocationOrigin;
private ActivityResultLauncher<String[]> locationPermissionLauncher;
@Override
@SuppressLint("SetJavaScriptEnabled")
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG);
locationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
result -> {
boolean granted =
Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))
|| Boolean.TRUE.equals(
result.get(Manifest.permission.ACCESS_COARSE_LOCATION)
);
completeLocationPermission(granted);
}
);
webView = new WebView(this);
webView.setLayoutParams(
new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
);
setContentView(webView);
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setGeolocationEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
settings.setMediaPlaybackRequiresUserGesture(true);
settings.setUserAgentString(settings.getUserAgentString() + " WhoNeedHelpAndroid/0.1");
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, false);
webView.setWebViewClient(new TrustedWebViewClient());
webView.setWebChromeClient(new LocationWebChromeClient());
getOnBackPressedDispatcher().addCallback(
this,
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
return;
}
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
}
);
if (savedInstanceState == null || webView.restoreState(savedInstanceState) == null) {
webView.loadUrl(initialUrl());
}
}
private String initialUrl() {
if (BuildConfig.DEBUG) {
String candidate = getIntent().getStringExtra("initial_url");
if (candidate != null && trustedOrigin.matches(candidate)) {
return candidate;
}
}
return trustedOrigin.startUrl();
}
private static String safeLogPath(Uri uri) {
String path = uri.getPath();
if (path != null && path.startsWith("/users/log-in/")) {
return "/users/log-in/[redacted]";
}
return path;
}
@Override
protected void onSaveInstanceState(Bundle outState) {
webView.saveState(outState);
super.onSaveInstanceState(outState);
}
@Override
protected void onPause() {
webView.onPause();
CookieManager.getInstance().flush();
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
webView.onResume();
}
@Override
protected void onDestroy() {
denyPendingLocation();
if (webView != null) {
webView.stopLoading();
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
}
super.onDestroy();
}
private void requestLocationFor(
String origin,
GeolocationPermissions.Callback callback
) {
if (!trustedOrigin.matchesOrigin(origin)) {
callback.invoke(origin, false, false);
return;
}
denyPendingLocation();
pendingLocationOrigin = origin;
pendingLocationCallback = callback;
boolean granted =
checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
if (granted) {
completeLocationPermission(true);
return;
}
locationPermissionLauncher.launch(
new String[] {
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
}
);
}
private void completeLocationPermission(boolean granted) {
if (pendingLocationCallback != null && pendingLocationOrigin != null) {
pendingLocationCallback.invoke(pendingLocationOrigin, granted, false);
}
pendingLocationCallback = null;
pendingLocationOrigin = null;
}
private void denyPendingLocation() {
completeLocationPermission(false);
}
private void openExternal(Uri uri) {
String scheme = uri.getScheme();
if (
scheme == null ||
!(
scheme.equalsIgnoreCase("http")
|| scheme.equalsIgnoreCase("https")
|| scheme.equalsIgnoreCase("mailto")
|| scheme.equalsIgnoreCase("tel")
)
) {
Toast.makeText(this, R.string.unsupported_link, Toast.LENGTH_SHORT).show();
return;
}
try {
startActivity(new Intent(Intent.ACTION_VIEW, uri));
} catch (ActivityNotFoundException exception) {
Toast.makeText(this, R.string.no_link_handler, Toast.LENGTH_SHORT).show();
}
}
private final class TrustedWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
if (trustedOrigin.matches(uri.toString())) {
return false;
}
openExternal(uri);
return true;
}
@Override
public void onReceivedSslError(
WebView view,
SslErrorHandler handler,
SslError error
) {
handler.cancel();
Toast.makeText(MainActivity.this, R.string.secure_connection_failed, Toast.LENGTH_LONG)
.show();
}
@Override
public void onReceivedError(
WebView view,
WebResourceRequest request,
WebResourceError error
) {
if (request.isForMainFrame()) {
if (BuildConfig.DEBUG) {
Log.e(
LOG_TAG,
"Main-frame load failed: code="
+ error.getErrorCode()
+ " path="
+ safeLogPath(request.getUrl())
);
}
Toast.makeText(MainActivity.this, R.string.page_load_failed, Toast.LENGTH_LONG)
.show();
}
}
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url)));
}
if (!trustedOrigin.matches(url)) {
view.stopLoading();
openExternal(Uri.parse(url));
}
}
@Override
public void onPageFinished(WebView view, String url) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url)));
scheduleMapDiagnostics(view, Uri.parse(url));
}
}
private void scheduleMapDiagnostics(WebView view, Uri uri) {
String path = uri.getPath();
if (path == null || !path.startsWith("/requests/")) {
return;
}
view.postDelayed(
() ->
view.evaluateJavascript(
"(() => {"
+ "const map=document.getElementById('show-map');"
+ "if(!map)return 'map=missing';"
+ "return 'map=present;canvas='+map.querySelectorAll('canvas').length"
+ "+';controls='+map.querySelectorAll('.maplibregl-ctrl').length"
+ "+';size='+map.clientWidth+'x'+map.clientHeight;"
+ "})()",
result -> Log.d(LOG_TAG, "Map diagnostics: " + result)
),
4_000
);
}
}
private final class LocationWebChromeClient extends WebChromeClient {
@Override
public void onGeolocationPermissionsShowPrompt(
String origin,
GeolocationPermissions.Callback callback
) {
requestLocationFor(origin, callback);
}
@Override
public void onGeolocationPermissionsHidePrompt() {
denyPendingLocation();
}
}
}

View File

@ -0,0 +1,87 @@
package org.whoneedhelp.mobile;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
final class TrustedOrigin {
private final URI base;
private final int port;
private TrustedOrigin(URI base) {
this.base = base;
this.port = effectivePort(base);
}
static TrustedOrigin parse(String value, boolean debugBuild) {
final URI uri;
try {
uri = new URI(value);
} catch (URISyntaxException exception) {
throw new IllegalArgumentException("BASE_URL is not a valid URI", exception);
}
String scheme = normalized(uri.getScheme());
boolean acceptedScheme = "https".equals(scheme) || (debugBuild && "http".equals(scheme));
if (
!acceptedScheme ||
uri.getHost() == null ||
uri.getHost().isBlank() ||
uri.getUserInfo() != null ||
uri.getQuery() != null ||
uri.getFragment() != null
) {
throw new IllegalArgumentException("BASE_URL must be an absolute trusted origin");
}
return new TrustedOrigin(uri.normalize());
}
String startUrl() {
return base.toString();
}
boolean matches(String value) {
try {
URI candidate = new URI(value);
return normalized(base.getScheme()).equals(normalized(candidate.getScheme()))
&& normalized(base.getHost()).equals(normalized(candidate.getHost()))
&& port == effectivePort(candidate)
&& candidate.getUserInfo() == null;
} catch (URISyntaxException exception) {
return false;
}
}
boolean matchesOrigin(String value) {
try {
URI candidate = new URI(value);
return matches(value)
&& pathIsOrigin(candidate.getPath())
&& candidate.getQuery() == null
&& candidate.getFragment() == null;
} catch (URISyntaxException exception) {
return false;
}
}
private static boolean pathIsOrigin(String path) {
return path == null || path.isEmpty() || "/".equals(path);
}
private static int effectivePort(URI uri) {
if (uri.getPort() != -1) {
return uri.getPort();
}
return "https".equals(normalized(uri.getScheme())) ? 443 : 80;
}
private static String normalized(String value) {
return value == null ? "" : value.toLowerCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#14532D"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M47,22h14v25h25v14h-25v25h-14v-25h-25v-14h25z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="brand_green">#14532D</color>
<color name="brand_green_dark">#0A2F1A</color>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Who Need Help</string>
<string name="no_link_handler">No app can open this link.</string>
<string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WhoNeedHelp" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">@color/brand_green</item>
<item name="android:navigationBarColor">@color/brand_green_dark</item>
<item name="android:statusBarColor">@color/brand_green</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

@ -0,0 +1,45 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class TrustedOriginTest {
@Test
public void acceptsSameHttpsOriginAndDefaultPort() {
TrustedOrigin origin = TrustedOrigin.parse("https://help.example", false);
assertTrue(origin.matches("https://help.example/requests/123"));
assertTrue(origin.matches("https://help.example:443/profile"));
assertFalse(origin.matches("https://help.example.evil.test/"));
assertFalse(origin.matches("http://help.example/"));
assertFalse(origin.matches("https://help.example:444/"));
}
@Test
public void debugBuildCanUseEmulatorLoopback() {
TrustedOrigin origin = TrustedOrigin.parse("http://10.0.2.2:4010", true);
assertTrue(origin.matches("http://10.0.2.2:4010/requests"));
assertTrue(origin.matchesOrigin("http://10.0.2.2:4010/"));
assertFalse(origin.matchesOrigin("http://10.0.2.2:4010/requests"));
}
@Test
public void releaseRejectsHttpAndCredentialedOrigins() {
assertThrows(
IllegalArgumentException.class,
() -> TrustedOrigin.parse("http://help.example", false)
);
assertThrows(
IllegalArgumentException.class,
() -> TrustedOrigin.parse("https://user@help.example", false)
);
assertThrows(
IllegalArgumentException.class,
() -> TrustedOrigin.parse("https://help.example?redirect=evil", false)
);
}
}

3
android/build.gradle.kts Normal file
View File

@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.3.0" apply false
}

View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true

Binary file not shown.

View File

@ -0,0 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored Executable file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WhoNeedHelp"
include(":app")

122
assets/css/app.css Normal file
View File

@ -0,0 +1,122 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@import "../node_modules/maplibre-gl/dist/maplibre-gl.css";
@import "phoenix-colocated/who_need_help/colocated.css";
@source "../css";
@source "../js";
@source "../../lib/who_need_help_web";
/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */
@source "../../_build/dev/phoenix-colocated/who_need_help/*/";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. */
@plugin "daisyui/packages/bundle/daisyui" {
themes: false;
}
/* daisyUI theme plugin.
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "daisyui/packages/bundle/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/packages/bundle/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */
.aid-map {
min-height: 22rem;
border-radius: 1.25rem;
overflow: hidden;
border: 1px solid color-mix(in oklab, currentColor 12%, transparent);
}
.help-card {
transition: transform 160ms ease, box-shadow 160ms ease;
}
.help-card:hover {
transform: translateY(-2px);
box-shadow: 0 18px 48px rgb(23 37 31 / 0.10);
}

91
assets/js/app.js Normal file
View File

@ -0,0 +1,91 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help"
import topbar from "../vendor/topbar"
import {Hooks} from "./hooks"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...Hooks},
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
window.addEventListener("phx:reset-message-form", ({detail}) => {
const form = document.getElementById(detail.id)
if (form instanceof HTMLFormElement) form.reset()
})
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js"))
}
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

108
assets/js/hooks.js Normal file
View File

@ -0,0 +1,108 @@
import maplibregl from "maplibre-gl"
const defaultStyle = {
version: 8,
sources: {
osm: {
type: "raster",
tiles: [document.documentElement.dataset.mapTileUrl],
tileSize: 256,
attribution: "© OpenStreetMap contributors"
}
},
layers: [{id: "osm", type: "raster", source: "osm"}]
}
export const Hooks = {
AidMap: {
mounted() {
this.markers = []
this.map = new maplibregl.Map({
container: this.el,
style: defaultStyle,
center: [30.5234, 50.4501],
zoom: 5
})
this.map.addControl(new maplibregl.NavigationControl(), "top-right")
this.renderMarkers()
},
updated() {
this.renderMarkers()
},
destroyed() {
this.map?.remove()
},
renderMarkers() {
if (!this.map) return
this.markers.forEach(marker => marker.remove())
this.markers = []
const points = JSON.parse(this.el.dataset.markers || "[]")
points.forEach(point => {
const popup = new maplibregl.Popup({offset: 18}).setHTML(
`<strong>${escapeHtml(point.title)}</strong><br>${escapeHtml(point.location)}`
)
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
.setLngLat([point.longitude, point.latitude])
.setPopup(popup)
.addTo(this.map)
this.markers.push(marker)
})
if (points.length === 1) {
this.map.flyTo({center: [points[0].longitude, points[0].latitude], zoom: 12})
} else if (points.length > 1) {
const bounds = new maplibregl.LngLatBounds()
points.forEach(point => bounds.extend([point.longitude, point.latitude]))
this.map.fitBounds(bounds, {padding: 52, maxZoom: 13})
}
}
},
LocationPicker: {
mounted() {
this.el.addEventListener("click", () => {
navigator.geolocation.getCurrentPosition(
position => {
document.querySelector("#request-latitude").value = position.coords.latitude
document.querySelector("#request-longitude").value = position.coords.longitude
this.el.textContent = "Location added"
this.el.classList.add("btn-success")
},
() => {
this.el.textContent = "Location permission denied"
this.el.classList.add("btn-error")
},
{enableHighAccuracy: true, timeout: 10000}
)
})
}
},
LiveTracking: {
mounted() {
this.destroying = false
this.watchId = navigator.geolocation.watchPosition(
position => this.pushEvent("location-update", {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy_meters: position.coords.accuracy
}),
() => {
if (!this.destroying) this.pushEvent("location-error", {})
},
{enableHighAccuracy: true, maximumAge: 5000, timeout: 15000}
)
},
destroyed() {
this.destroying = true
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
}
}
}
function escapeHtml(value) {
const node = document.createElement("div")
node.textContent = value || ""
return node.innerHTML
}

250
assets/package-lock.json generated Normal file
View File

@ -0,0 +1,250 @@
{
"name": "who-need-help-assets",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "who-need-help-assets",
"dependencies": {
"maplibre-gl": "5.24.0"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
}
}
}

7
assets/package.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "who-need-help-assets",
"private": true,
"dependencies": {
"maplibre-gl": "5.24.0"
}
}

32
assets/tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}

43
assets/vendor/heroicons.js vendored Normal file
View File

@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})

138
assets/vendor/topbar.js vendored Normal file
View File

@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

118
compose.yaml Normal file
View File

@ -0,0 +1,118 @@
name: who_need_help
x-app-environment: &app-environment
APP_ROLE: web
DATABASE_URL: ecto://postgres:postgres@db/who_need_help
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-h0rJQH8xgH9vV1cS7gVw6M2oI0Kp3Lx9uA5fE4bD8nR7qT2yW6zC1sP9mN3kF5jH}
HANDOVER_SECRET: ${HANDOVER_SECRET:-local-compose-handover-secret-change-before-public-use}
RELEASE_COOKIE: ${RELEASE_COOKIE:-local-compose-beam-cookie-change-before-public-use}
DNS_CLUSTER_QUERY: web
PHX_HOST: ${PHX_HOST:-localhost}
PHX_SCHEME: http
PHX_URL_PORT: ${HTTP_PORT:-4010}
PORT: "4000"
POOL_SIZE: ${POOL_SIZE:-10}
SMTP_RELAY: mailpit
SMTP_PORT: "1025"
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
services:
proxy:
image: traefik:v3.7
command:
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
ports:
- "${HTTP_PORT:-4010}:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [edge, internal]
restart: unless-stopped
db:
image: postgis/postgis:18-3.6-alpine
environment:
POSTGRES_DB: who_need_help
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d who_need_help"]
interval: 3s
timeout: 3s
retries: 20
volumes:
- postgres_data:/var/lib/postgresql
networks: [internal]
restart: unless-stopped
mailpit:
image: axllent/mailpit:v1.27
ports:
- "${MAILPIT_PORT:-8027}:8025"
networks: [edge, internal]
restart: unless-stopped
migrate:
image: who-need-help:local
build:
context: .
environment:
<<: *app-environment
APP_ROLE: migrate
command: ["/app/bin/migrate"]
depends_on:
db:
condition: service_healthy
networks: [internal]
restart: "no"
web:
image: who-need-help:local
environment:
<<: *app-environment
APP_ROLE: web
PHX_SERVER: "true"
depends_on:
migrate:
condition: service_completed_successfully
labels:
- traefik.enable=true
- traefik.docker.network=who_need_help_internal
- traefik.http.routers.who-need-help.rule=PathPrefix(`/`)
- traefik.http.routers.who-need-help.entrypoints=web
- traefik.http.services.who-need-help.loadbalancer.server.port=4000
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s
timeout: 3s
retries: 10
deploy:
replicas: 2
networks: [internal]
restart: unless-stopped
worker:
image: who-need-help:local
environment:
<<: *app-environment
APP_ROLE: worker
command: ["/app/bin/who_need_help", "start"]
depends_on:
migrate:
condition: service_completed_successfully
deploy:
replicas: 2
networks: [internal]
restart: unless-stopped
networks:
edge:
internal:
internal: true
volumes:
postgres_data:

101
config/config.exs Normal file
View File

@ -0,0 +1,101 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :who_need_help, :scopes,
user: [
default: true,
module: WhoNeedHelp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :binary_id,
schema_table: :users,
test_data_fixture: WhoNeedHelp.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
config :who_need_help,
ecto_repos: [WhoNeedHelp.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true],
app_role: :web,
handover_secret: "development-only-handover-secret",
codex_session_id: "not-configured",
rate_limit_policies: %{},
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes
config :geo_postgis, json_library: Jason
config :who_need_help, Oban,
repo: WhoNeedHelp.Repo,
queues: [default: 10, maintenance: 2],
plugins: [
{Oban.Plugins.Pruner, max_age: 86_400},
{Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]}
]
# Configure the endpoint
config :who_need_help, WhoNeedHelpWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: WhoNeedHelpWeb.ErrorHTML, json: WhoNeedHelpWeb.ErrorJSON],
layout: false
],
pubsub_server: WhoNeedHelp.PubSub,
live_view: [signing_salt: "IdtA/aHu"]
# Configure LiveView
config :phoenix_live_view,
# the attribute set on all root tags. Used for Phoenix.LiveView.ColocatedCSS.
root_tag_attribute: "phx-r"
# Configure the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
who_need_help: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.3.0",
who_need_help: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

78
config/dev.exs Normal file
View File

@ -0,0 +1,78 @@
import Config
# Configure your database
config :who_need_help, WhoNeedHelp.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "who_need_help_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :who_need_help, WhoNeedHelpWeb.Endpoint,
# Bind to 0.0.0.0 to expose the server to the docker host machine.
# This makes make the service accessible from any network interface.
# Change to `ip: {127, 0, 0, 1}` to allow access only from the server machine.
http: [ip: {0, 0, 0, 0}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "5RZJQtMxKgyH40kg8va/QVQx81vCSeF9Io7sQGEp9YnSFogb2SzJpMFYffHCVU8Z",
watchers: [
esbuild: {Esbuild, :install_and_run, [:who_need_help, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:who_need_help, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Enable dev routes for dashboard and mailbox
config :who_need_help, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

33
config/prod.exs Normal file
View File

@ -0,0 +1,33 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :who_need_help, WhoNeedHelpWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :who_need_help, WhoNeedHelpWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
exclude: [
paths: ["/healthz/live", "/healthz/ready"],
hosts: ["localhost", "127.0.0.1"]
]
]
# Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

187
config/runtime.exs Normal file
View File

@ -0,0 +1,187 @@
import Config
app_role =
case System.get_env("APP_ROLE", "web") do
"web" -> :web
"worker" -> :worker
"migrate" -> :migrate
other -> raise "APP_ROLE must be web, worker, or migrate; got #{inspect(other)}"
end
config :who_need_help,
app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
map_tile_url:
System.get_env(
"MAP_TILE_URL",
Application.fetch_env!(:who_need_help, :map_tile_url)
),
rate_limit_policies:
(case System.get_env("RATE_LIMIT_POLICIES_JSON") do
nil -> %{}
"" -> %{}
json -> Jason.decode!(json)
end)
if dns_query = System.get_env("DNS_CLUSTER_QUERY") do
config :who_need_help, :dns_cluster_query, dns_query
end
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/who_need_help start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :who_need_help, WhoNeedHelpWeb.Endpoint, server: true
end
config :who_need_help, WhoNeedHelpWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
if config_env() == :dev do
# Reload browser tabs when matching files change.
config :who_need_help, WhoNeedHelpWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
# Gettext translations
~r"priv/gettext/.*\.po$",
# Router, Controllers, LiveViews and LiveComponents
~r"lib/who_need_help_web/router\.ex$",
~r"lib/who_need_help_web/(controllers|live|components)/.*\.(ex|heex)$"
]
]
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :who_need_help, WhoNeedHelp.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
handover_secret =
System.get_env("HANDOVER_SECRET") ||
raise """
environment variable HANDOVER_SECRET is missing.
Generate an independent random value for deterministic handover codes.
"""
host = System.get_env("PHX_HOST") || "example.com"
scheme = System.get_env("PHX_SCHEME", "https")
unless scheme in ["http", "https"] do
raise "PHX_SCHEME must be http or https; got #{inspect(scheme)}"
end
default_url_port = if scheme == "https", do: "443", else: "80"
url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port))
config :who_need_help, :handover_secret, handover_secret
config :who_need_help, WhoNeedHelp.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_RELAY", "mailpit"),
port: String.to_integer(System.get_env("SMTP_PORT", "1025")),
auth: :never,
tls: :never,
ssl: false
config :who_need_help, WhoNeedHelpWeb.Endpoint,
url: [host: host, port: url_port, scheme: scheme],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :who_need_help, WhoNeedHelpWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :who_need_help, WhoNeedHelpWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :who_need_help, WhoNeedHelp.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://swoosh.hexdocs.pm/Swoosh.html#module-installation for details.
end

45
config/test.exs Normal file
View File

@ -0,0 +1,45 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :who_need_help, WhoNeedHelp.Repo,
username: "postgres",
password: "postgres",
hostname: System.get_env("DB_HOST", "localhost"),
port: String.to_integer(System.get_env("DB_PORT", "5432")),
database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: String.to_integer(System.get_env("TEST_POOL_SIZE", "10"))
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :who_need_help, WhoNeedHelpWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "s1PD9eWfZ3XzprmZQ0Wx0CjdKGLDZyXeXzvfqsnNaPS+kUaZ6QRNtq0mPVjh+e9C",
server: false
# In test we don't send emails
config :who_need_help, WhoNeedHelp.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

View File

@ -0,0 +1,7 @@
apiVersion: v2
name: who-need-help
description: Horizontally scalable Phoenix web and Oban worker deployment
type: application
version: 0.1.0
appVersion: "0.1.0"
kubeVersion: ">=1.30.0-0"

View File

@ -0,0 +1,26 @@
{{- define "who-need-help.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "who-need-help.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name (include "who-need-help.name" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "who-need-help.labels" -}}
app.kubernetes.io/name: {{ include "who-need-help.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "who-need-help.selectorLabels" -}}
app.kubernetes.io/name: {{ include "who-need-help.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "who-need-help.secretName" -}}
{{- default (include "who-need-help.fullname" .) .Values.existingSecret }}
{{- end }}

View File

@ -0,0 +1,121 @@
{{- $root := . -}}
{{- range $component, $settings := dict "web" .Values.web "worker" .Values.worker }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "who-need-help.fullname" $root }}-{{ $component }}
labels:
{{- include "who-need-help.labels" $root | nindent 4 }}
app.kubernetes.io/component: {{ $component }}
spec:
replicas: {{ $settings.replicas }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
{{- include "who-need-help.selectorLabels" $root | nindent 6 }}
app.kubernetes.io/component: {{ $component }}
template:
metadata:
annotations:
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") $root | sha256sum }}
{{- with $root.Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "who-need-help.selectorLabels" $root | nindent 8 }}
app.kubernetes.io/component: {{ $component }}
{{- with $root.Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
subdomain: {{ include "who-need-help.fullname" $root }}-headless
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
initContainers:
- name: await-migrations
image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}"
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
command: ["/app/bin/await_migrations"]
envFrom:
- secretRef:
name: {{ include "who-need-help.secretName" $root }}
env:
- name: APP_ROLE
value: migrate
containers:
- name: {{ $component }}
image: "{{ $root.Values.image.repository }}:{{ $root.Values.image.tag }}"
imagePullPolicy: {{ $root.Values.image.pullPolicy }}
{{- if eq $component "worker" }}
command: ["/app/bin/who_need_help", "start"]
{{- end }}
envFrom:
- secretRef:
name: {{ include "who-need-help.secretName" $root }}
env:
- name: APP_ROLE
value: {{ $component }}
- name: PHX_SERVER
value: {{ if eq $component "web" }}"true"{{ else }}"false"{{ end }}
- name: PHX_HOST
value: {{ $root.Values.app.host | quote }}
- name: PHX_SCHEME
value: {{ $root.Values.app.scheme | quote }}
- name: PHX_URL_PORT
value: {{ $root.Values.app.urlPort | quote }}
- name: PORT
value: {{ $root.Values.app.port | quote }}
- name: POOL_SIZE
value: {{ $root.Values.app.poolSize | quote }}
- name: SMTP_RELAY
value: {{ $root.Values.app.smtpRelay | quote }}
- name: SMTP_PORT
value: {{ $root.Values.app.smtpPort | quote }}
- name: CODEX_SESSION_ID
value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
- name: MAP_TILE_URL
value: {{ $root.Values.app.mapTileUrl | quote }}
- name: DNS_CLUSTER_QUERY
value: "{{ include "who-need-help.fullname" $root }}-headless.{{ $root.Release.Namespace }}.svc.cluster.local"
{{- if eq $component "web" }}
ports:
- name: http
containerPort: 4000
readinessProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz/live
port: http
periodSeconds: 10
{{- end }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
{{- with $root.Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $root.Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $root.Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
{{- end }}

View File

@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "who-need-help.fullname" . }}
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "who-need-help.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,40 @@
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ include "who-need-help.fullname" . }}-migrate-{{ .Release.Revision }}"
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
app.kubernetes.io/component: migrate
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
backoffLimit: 4
template:
metadata:
labels:
{{- include "who-need-help.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/app/bin/migrate"]
envFrom:
- secretRef:
name: {{ include "who-need-help.secretName" . }}
env:
- name: APP_ROLE
value: migrate
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

View File

@ -0,0 +1,25 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "who-need-help.fullname" . }}-web
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
spec:
minAvailable: 1
selector:
matchLabels:
{{- include "who-need-help.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: web
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "who-need-help.fullname" . }}-worker
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
spec:
minAvailable: 1
selector:
matchLabels:
{{- include "who-need-help.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: worker

View File

@ -0,0 +1,18 @@
{{- if not .Values.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "who-need-help.fullname" . }}
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
annotations:
helm.sh/hook: pre-install,pre-upgrade
helm.sh/hook-weight: "-10"
helm.sh/hook-delete-policy: before-hook-creation
type: Opaque
stringData:
DATABASE_URL: {{ .Values.app.databaseUrl | quote }}
SECRET_KEY_BASE: {{ .Values.app.secretKeyBase | quote }}
HANDOVER_SECRET: {{ .Values.app.handoverSecret | quote }}
RELEASE_COOKIE: {{ .Values.app.releaseCookie | quote }}
{{- end }}

View File

@ -0,0 +1,34 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "who-need-help.fullname" . }}
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "who-need-help.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: web
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
{{- with .Values.service.nodePort }}
nodePort: {{ . }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "who-need-help.fullname" . }}-headless
labels:
{{- include "who-need-help.labels" . | nindent 4 }}
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
{{- include "who-need-help.selectorLabels" . | nindent 4 }}
ports:
- name: epmd
port: 4369
targetPort: 4369

View File

@ -0,0 +1,18 @@
image:
repository: who-need-help
tag: local
pullPolicy: IfNotPresent
service:
type: NodePort
port: 80
nodePort: 30080
app:
host: localhost
scheme: http
urlPort: "4011"
databaseUrl: ecto://postgres:postgres@postgis/who_need_help
secretKeyBase: y9XxDIDxcn8uHarbX1B4feQl0xXi413tdYO472d758DtvslbtlVdUs2HdxPEPXCQ
handoverSecret: L2a4yz6nLYkWvIBTlREj8uDByngsY39JfLIfdYNDH/7dnIQEVW4T8OCjCkHOGZ7H
releaseCookie: OwNDSCX7aQkkNXovILLiESVolaRuYm67xC+JbMC3j3ERTY6sHIM/F4gc0elm1l07

View File

@ -0,0 +1,51 @@
image:
repository: who-need-help
tag: local
pullPolicy: IfNotPresent
web:
replicas: 2
worker:
replicas: 2
service:
type: ClusterIP
port: 80
nodePort: null
app:
host: localhost
scheme: https
urlPort: "443"
port: "4000"
poolSize: "10"
databaseUrl: ecto://postgres:postgres@postgis/who_need_help
secretKeyBase: replace-before-public-deployment
handoverSecret: replace-before-public-deployment
releaseCookie: replace-before-public-deployment
codexSessionId: not-configured
# Shared limits are opt-in; set only after product policy thresholds are approved.
rateLimitPoliciesJson: "{}"
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
smtpRelay: mailpit
smtpPort: "1025"
existingSecret: ""
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: who-need-help.local
paths:
- path: /
pathType: Prefix
tls: []
podAnnotations: {}
podLabels: {}
nodeSelector: {}
tolerations: []
affinity: {}

12
deploy/kind/cluster.yaml Normal file
View File

@ -0,0 +1,12 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: who-need-help
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 4011
protocol: TCP
- containerPort: 30227
hostPort: 8028
protocol: TCP

View File

@ -0,0 +1,69 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgis
spec:
replicas: 1
selector:
matchLabels: {app: postgis}
template:
metadata:
labels: {app: postgis}
spec:
containers:
- name: postgis
image: postgis/postgis:18-3.6-alpine
env:
- {name: POSTGRES_DB, value: who_need_help}
- {name: POSTGRES_USER, value: postgres}
- {name: POSTGRES_PASSWORD, value: postgres}
ports:
- {name: postgres, containerPort: 5432}
readinessProbe:
exec:
command: ["pg_isready", "-U", "postgres", "-d", "who_need_help"]
periodSeconds: 3
volumeMounts:
- {name: data, mountPath: /var/lib/postgresql}
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgis
spec:
selector: {app: postgis}
ports:
- {name: postgres, port: 5432, targetPort: postgres}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mailpit
spec:
replicas: 1
selector:
matchLabels: {app: mailpit}
template:
metadata:
labels: {app: mailpit}
spec:
containers:
- name: mailpit
image: axllent/mailpit:v1.27
ports:
- {name: smtp, containerPort: 1025}
- {name: http, containerPort: 8025}
---
apiVersion: v1
kind: Service
metadata:
name: mailpit
spec:
type: NodePort
selector: {app: mailpit}
ports:
- {name: smtp, port: 1025, targetPort: smtp}
- {name: http, port: 8025, targetPort: http, nodePort: 30227}

171
docs/architecture.md Normal file
View File

@ -0,0 +1,171 @@
# Architecture
Status: target architecture for the first working release.
## System shape
Who Need Help is a modular Phoenix application rather than a collection of
premature microservices. It produces one immutable image with three runtime
roles:
- `web`: Phoenix Endpoint, LiveView, PubSub, and Presence.
- `worker`: Oban queues and scheduled jobs; no public HTTP listener.
- `migrate`: a one-shot database migration command before rollout.
PostgreSQL with PostGIS is the system of record. User-visible writes are
committed to PostgreSQL before a PubSub notification is broadcast. This makes
realtime delivery recoverable: after a reconnect, the client reads authoritative
state from the database.
```text
browser/PWA or Android WebView
Traefik / Kubernetes Service
├── web replica A ─┐
└── web replica B ─┼── PostgreSQL + PostGIS
worker A/B ─────┤
└── Oban jobs
web and worker nodes form a BEAM cluster for distributed PubSub/Presence
```
The Android module is a thin same-origin WebView shell rather than a separate
API client. Its debug origin is supplied at build time from the repository's
ignored `.env`; release builds require an explicit HTTPS origin. Authentication
cookies, LiveView WebSockets, MapLibre, private chat, and foreground
geolocation therefore use the same Phoenix application paths and authorization
rules as the browser. Production signing and distribution are separate
operational work and are not represented as complete.
## Application boundaries
- `Accounts`: users, authentication, social identities, privacy preferences,
blocks, and roles.
- `Catalog`: category tree, proposals, votes, moderation decisions, and
translated labels.
- `Help`: requests, assignments, state transitions, handover codes, and
completion evidence.
- `Messaging`: match chat and delivery receipts.
- `Tracking`: foreground sharing sessions, current positions, and derived
proximity signals.
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
abuse signals, moderator audit events, and shared rate-limit policies.
Contexts normally call each other through public functions. A small number of
documented trust-and-safety transactions update related schemas together when
atomic cleanup or moderation requires it; those operations stay in context
functions rather than controllers or LiveViews.
## Realtime and clustering
Phoenix PubSub and Presence handle transient fan-out. Phoenix's generated
`dns_cluster` dependency discovers nodes using DNS polling:
- Compose nodes discover the `web` service on the shared internal network;
workers join through those nodes and distributed Erlang forms the full mesh.
- Kubernetes uses a headless service.
No sticky session is required for authenticated requests. Session cookies are
signed by a shared secret. Uploads, if later introduced, must go to shared
object storage rather than a container filesystem.
Oban is enabled only for the worker role. PostgreSQL coordinates queues and
leadership, so no Redis dependency is introduced.
## Geospatial data
Locations are stored as WGS84 coordinates through the PostGIS Ecto extension.
Current movement and proximity evidence is derived in the application from two
current points and their browser-reported accuracy envelopes; no route is
retained. MapLibre GL JS renders the map through a small JavaScript hook. The
tile/style URL and attribution are configuration, not hard-coded provider
assumptions. Production operators must use a tile service whose policy and
capacity fit the traffic; the public OpenStreetMap tile service is best-effort
and has a usage policy, not an application backend.
## Data durability and retention
- Requests, state transitions, messages, reviews, reports, and audit events are
durable database records.
- A tracking session stores only its current position while active, never a
route history.
- Stopping sharing, completing/cancelling a match, or blocking the counterpart
deletes exact position records. Derived sample count, uncertainty-adjusted
distance, movement timestamp, and proximity timestamp remain.
- Secrets, exact coordinates, and private chat are excluded from local Codex
moderation inputs.
## Deployment
### Docker Compose
Normal development starts:
- Traefik
- 2 × web
- 2 × worker
- 1 × PostGIS
- 1 × Mailpit
Compose services have no fixed container names, enabling replicas. The project
uses an isolated Compose project name and network. PostgreSQL is intentionally
single-instance in the local profile; multiple uncoordinated containers would
not create database high availability.
### Kubernetes
The Helm chart contains separate web and worker Deployments, Services, a
headless cluster-discovery Service, a migration Job, Secret interfaces, probes,
and disruption-aware rolling updates. It does not invent
CPU/memory limits or an HPA threshold before measurements exist.
The bundled kind path is for reproducible local verification. A production
database should be an independently operated PostgreSQL/PostGIS service with
backups and a tested recovery procedure.
## Shared abuse counters
Action buckets live in PostgreSQL and use an atomic upsert keyed by action,
hashed scope, and aligned time window. This works across all web replicas
without an in-memory or Redis singleton. Policies are supplied through
`RATE_LIMIT_POLICIES_JSON`; no numeric product policy is compiled into the
application. Expired buckets are pruned by the maintenance worker.
The Compose and kind scripts finish by subscribing on one live BEAM node,
broadcasting through a different connected node, and failing if the PubSub
probe is not received.
## AI boundary
AI is not in the critical request or safety path. An administrator can export a
redacted batch of category proposals to the locally installed Codex CLI:
```text
codex exec --ephemeral --sandbox read-only --output-schema …
```
The process uses the user's ChatGPT-authenticated local Codex session. It never
uses an OpenAI API key, usage-based API billing, or an API fallback. The model
may group duplicates and draft English, Ukrainian, and Russian labels; a human
moderator applies every change.
## Versioning policy
Exact image and package versions are pinned in the repository after successful
build verification. Renovation is a deliberate change accompanied by tests,
not an implicit `latest` pull.
## Primary references
- [Phoenix 1.8 documentation](https://hexdocs.pm/phoenix/overview.html)
- [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html)
- [Phoenix Presence](https://hexdocs.pm/phoenix/presence.html)
- [Oban](https://hexdocs.pm/oban/Oban.html)
- [DNSCluster](https://hexdocs.pm/dns_cluster/DNSCluster.html)
- [PostGIS](https://postgis.net/documentation/)
- [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/)
- [OpenStreetMap tile usage policy](https://operations.osmfoundation.org/policies/tiles/)
- [W3C Geolocation](https://www.w3.org/TR/geolocation/)

View File

@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["recommendations"],
"properties": {
"recommendations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["proposal_id", "decision", "rationale", "suggested_slug", "merge_target_slug"],
"properties": {
"proposal_id": {"type": "string"},
"decision": {
"type": "string",
"enum": ["approve", "reject", "merge", "manual_review"]
},
"rationale": {"type": "string"},
"suggested_slug": {"type": ["string", "null"]},
"merge_target_slug": {"type": ["string", "null"]}
}
}
}
}
}

View File

@ -0,0 +1,54 @@
# ADR 0001: PostgreSQL/PostGIS instead of SpacetimeDB
- Status: accepted
- Date: 2026-07-18
## Context
The system needs durable relational transactions, geospatial proximity queries,
authentication data, chat history, trust/audit records, background jobs, Ecto
integration, and horizontal Phoenix/Oban nodes.
SpacetimeDB was considered because its server-authoritative realtime model is
interesting for live applications.
## Decision
Use PostgreSQL with PostGIS as the system of record, Ecto for persistence,
Oban for durable jobs, and Phoenix PubSub/Presence for transient realtime
fan-out.
## Evidence
At the time of this decision, SpacetimeDB's official language support does not
list an Elixir server module or client SDK. Its PGWire documentation describes
Simple Query support and limitations around parameterized queries, user
transactions, catalogs, and roles. Those constraints do not match a conventional
Ecto/Oban application. Its self-hosted deployment documentation describes a
standalone process, while replicated operation is a different deployment
offering.
These are integration constraints, not a general judgment that SpacetimeDB is
inferior.
## Consequences
- The application uses well-supported Ecto and PostGIS queries.
- Database-backed Oban coordinates multiple worker replicas.
- Phoenix clients reload durable state after PubSub loss or reconnect.
- A single local PostgreSQL container is not described as highly available.
- Production database HA, backups, and recovery are an operational concern
separate from web/worker horizontal scaling.
- SpacetimeDB can be reconsidered for a bounded realtime subsystem if official
Elixir/Ecto-compatible integration and deployment needs change.
## References
- [SpacetimeDB FAQ](https://spacetimedb.com/docs/intro/faq/)
- [Language support](https://spacetimedb.com/docs/intro/language-support/)
- [PostgreSQL wire protocol](https://spacetimedb.com/docs/how-to/pg-wire/)
- [Commit log internals](https://spacetimedb.com/docs/reference/internals/commitlog/)
- [Self-hosting](https://spacetimedb.com/docs/how-to/deploy/self-hosting/)
- [SpacetimeDB source and license](https://github.com/clockworklabs/SpacetimeDB)
- [Ecto](https://hexdocs.pm/ecto/Ecto.html)
- [PostGIS](https://postgis.net/documentation/)

118
docs/product-spec.md Normal file
View File

@ -0,0 +1,118 @@
# Who Need Help — product specification
Status: MVP specification for OpenAI Build Week, 2026-07-18.
## Purpose
Who Need Help connects an adult who needs urgent, practical nearby help with an
adult volunteer who can provide it without a mandatory fee.
The hackathon MVP is deliberately narrow: a requester can ask a nearby person
to pick up and deliver medication that has already been legally purchased or
reserved. The product does not diagnose, prescribe, recommend, sell, reimburse,
or handle controlled substances.
## Primary user journey
1. A user creates an account and self-attests that they are at least 18.
2. The user creates an urgent request with a title, instructions, approximate
public location, expiry time, and medicine-pickup category.
3. Nearby helpers see the request on a list and map.
4. One helper accepts it; requester and helper receive a private matched chat.
5. Either party may share foreground location for the active match.
6. The requester gives the helper a one-time handover code after receiving help.
7. Both sides confirm completion and may leave a double-blind review.
8. The requester may send a voluntary thank-you through a helper-provided
external link. The platform does not collect or route money.
## Request lifecycle
`open → matched → in_progress → completed`
Terminal alternatives are `cancelled` and `expired`. The MVP permits one active
helper per request. Every state transition is authorized and recorded.
## MVP scope
- Email magic-link/password authentication.
- English default interface plus Ukrainian and Russian translations.
- Medicine pickup/delivery requests.
- Hierarchical, data-driven categories with validated structured request fields.
- Request list, filters, details, and a MapLibre map.
- Matching, presence, chat, and foreground location updates.
- Configurable location privacy.
- One-time handover confirmation.
- Double-blind reviews and a reputation summary.
- Category proposals and voting.
- Bidirectional blocks, scoped request/assignment/message reports, and audited
report-evidence access.
- Role-protected report, account, abuse-signal, request, role, and category
moderation.
- A helper leaderboard based primarily on unique location-supported and
handover-verified counterpart counts.
- Configurable PostgreSQL-backed action limits shared by every web replica.
- PWA installation and foreground geolocation.
- Native Android WebView client for the same-origin authenticated map, matched
chat, and foreground geolocation flows; reproducible debug APK build.
- Admin-only local Codex moderation batch for category proposals.
## Explicitly outside the MVP
- Medical advice, prescriptions, pharmacy integrations, medication sale, and
controlled-substance delivery.
- Platform payments, escrow, fees, or compulsory compensation.
- Background PWA location tracking.
- Multiple simultaneous helpers on one request.
- Public activities such as coffee, cinema, or hiking. These will be a separate
future `Activity` mode, not an urgent-help category.
- Production-signed Android release, store publication, and native iOS
application.
- OAuth verification and automated social-network identity checks. The MVP
supports manually attached public links and marks them unverified.
- Automatic punitive abuse enforcement and unapproved production thresholds.
- Claims that identity, safety, or fraud prevention is perfect.
## Extensibility
Categories form a moderated tree and are not compiled into application code.
After the MVP the same request workflow can support fuel delivery, roadside
help, bicycle punctures, broken motorcycle chains, and urgent household help.
Each category can add validated text, select, and boolean fields through its
stored schema without changing the request form code or core state machine.
Social links are user-provided references and are visibly marked unverified.
The data model reserves `verified_at` for a future OAuth flow, but the current
MVP does not set it.
## Privacy settings
Every user chooses one location visibility level:
- `hidden`
- `approximate_public` (default)
- `exact_for_active_match`
- `exact_public` (explicit per-request opt-in)
Public discovery uses either no marker, an approximate marker, or an explicitly
public exact marker. Exact active coordinates are ephemeral and no route
history is stored. The current point is deleted when sharing ends, a match
becomes terminal, or either participant blocks the other. Derived trust signals
may be retained.
The data model reserves a direct-message policy for a future general messaging
feature. The current product has no unsolicited inbox: chat exists only for
matched participants, and a block prevents new messages.
## Success criteria for the hackathon
- A new user can complete the primary journey locally in two browser sessions.
- The debug Android client can authenticate against the local stack and use
the request map, matched chat, and foreground sharing on an emulator.
- The same immutable image runs as both web and worker roles.
- Two web replicas and two worker replicas run concurrently in normal
development.
- A request, chat message, and location update reach clients connected to
different web replicas.
- The repository contains reproducible Compose and Kubernetes deployment paths.
- The feedback page records the main local Codex session identifier for the
Build Week submission.

114
docs/trust-safety.md Normal file
View File

@ -0,0 +1,114 @@
# Trust, safety, and abuse model
Status: MVP policy and implementation requirements. This document does not
claim that the system can eliminate bad actors.
## Safety boundary
Who Need Help is a coordination tool for adults. It is not an emergency service.
Every urgent screen must tell users to contact local emergency services when
life, health, fire, violence, or immediate danger is involved.
The medicine category is limited to pickup/delivery of an already purchased or
reserved legal item. Requests for medical advice, prescriptions, cash advances,
controlled substances, or suspicious repackaging are reportable and removable.
## Verified completion
A completion is counted as verified only when all of these occur:
1. A helper accepted the request.
2. Both parties confirmed the completion.
3. The requester shared the one-time handover code with the helper.
Optional location proximity is supporting evidence, never a mandatory
prerequisite. This avoids excluding users who deny geolocation permission,
while making simple remote rating rings less useful.
The public reputation summary separates:
- completed requests;
- unique people helped;
- verified handovers.
- optional location-supported interactions.
The helper leaderboard ranks unique counterparts with optional proximity plus
helper-movement evidence first, then unique handover-verified counterparts,
then unique counterparts, and only then raw completions. Repeated work with the
same person cannot increase the primary unique counters.
## Reviews
Reviews are double-blind. A review becomes visible only after both participants
submit. There is no automatic reveal timeout in the MVP. Reviews are allowed
only for completed matched requests.
## Identity and social links
Email ownership can be confirmed through a magic link. Manually attached
Instagram, Facebook, Telegram, Google, or other URLs are labelled “unverified.”
OAuth verification is not implemented in the current MVP.
The MVP uses 18+ self-attestation. It does not imply government identity
verification.
## Implemented abuse controls
- Completion requires both confirmations plus a request-specific handover code.
- Public reputation separates completed work, unique people, and handover-code
verification.
- Reviews require a completed assignment and remain hidden until both parties
submit.
- Blocks remove discovery in both directions, prevent acceptance/new chat, and
delete the pair's active exact positions.
- Reports target exactly one request, assignment, or message. A moderator can
load chat only through an assignment/message report.
- Missing optional proximity/movement, repeated pairs, reciprocal direction,
and configured action velocity create review signals. They do not
automatically punish an account.
- PostgreSQL action buckets enforce only operator-supplied policies across all
replicas. Registration and magic-link email scopes can also be configured.
- No public exact location by default.
No numeric rate policy is enabled by default because no threshold has been
approved or measured for this deployment. Supported action names currently
include `registration_email`, `magic_link_email`, `create_request`,
`accept_request`, `start`, `confirm`, `verify_handover`, `cancel_request`,
`withdraw_assignment`, `send_message`, `start_tracking`, `tracking_position`,
`review`, `report`, `block`, `category_proposal`, and `category_vote`.
The system does not claim to be bot-proof. Email confirmation, database
uniqueness, unique-counterpart ranking, location evidence, velocity policies,
signals, and human review raise the cost of simple rating farms but cannot prove
identity or eliminate coordinated abuse.
## Moderator access
A moderator can access private chat only from a specific assignment or message
report. Every access records the moderator, report, linked assignment, message
count, and timestamp. The report itself contains the reason. Browsing private
conversations without a qualifying report is not a product capability.
Report, request, signal, category, role, and account moderation are role
protected. The local Codex
batch receives proposal text and aggregate vote counts only. It receives no
private messages, email addresses, OAuth tokens, exact coordinates, or raw
tracking routes.
## Money
Help is free. A helper may add an external voluntary tip link. It appears after
completion, is labelled optional, and routes directly to the helper. The
platform does not calculate, collect, split, refund, guarantee, or report the
payment. The legal and tax treatment remains dependent on the users'
jurisdiction and is not represented by the product.
## Operational response
Reports have `open`, `reviewing`, `resolved`, and `dismissed` states. A severe
report can hide a request and temporarily restrict an account pending review.
Suspension invalidates that account's login sessions. The first administrator
requires an explicit one-time operational bootstrap, and later role changes are
audited; the last administrator cannot demote themselves.
The operator must publish jurisdiction-specific emergency contacts, privacy
notice, prohibited-items policy, and data-retention policy before public launch.

89
docs/verification.md Normal file
View File

@ -0,0 +1,89 @@
# Who Need Help — implementation verification
Observed on 2026-07-18 in the local workspace. This report separates observed
results from product limits and unknown production properties.
## Verified MVP capabilities
| Requirement | Status | Observed evidence | Limit |
| --- | --- | --- | --- |
| Urgent medicine-help flow | Implemented and tested | Request creation, discovery, matching, start, handover, two-party completion, and review rules are covered by the Phoenix test suite and exercised in the local UI. | The product coordinates pickup of an already purchased or reserved legal item; it is not a pharmacy, medical, or emergency service. |
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities are intentionally a future `Activity` mode. |
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
| Foreground tracking | Implemented and cross-client verified | Android emulator coordinates created an active tracking session and appeared for the matched requester. Stopping sharing made the session inactive and removed raw current positions. | No background tracking or route history is implemented. |
| 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. |
| Social profiles | Implemented as manual links | Public links can be attached and are visibly labelled unverified. | OAuth/social-network verification is not implemented. |
| 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 | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login persistence, map, chat, and foreground geolocation were exercised on API 37. The final Docker emulator image separately passed boot/install/launch smoke verification. | Production signing, a public production origin, Play Store publication, background tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
## Reproducible checks
- `./scripts/test.sh`: 121 tests, 0 failures in the final run.
- `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings.
- Release Android guard: a release build without `WNH_BASE_URL` failed as
intended; release configuration accepts only an explicit HTTPS origin.
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
checks, and cross-node PubSub verification passed in the local kind cluster.
- Browser verification used headed Chrome. Fresh requester and helper pages
reported no console errors or warnings after the final rollout.
Local generated evidence (ignored by Git):
- `output/playwright/final-request-map-chat.png`
- `output/playwright/final-privacy-profile.png`
- `output/android/final-image-smoke.png`
Android artifact:
- `android/dist/who-need-help-debug.apk`
- SHA-256:
`4df66ec90e056c70f08841db2f0aa6178ba9cb64d4882478cd2cba88f5731003`
- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and
compile SDK 37.
## Configuration finding
`WNH_DEBUG_BASE_URL` is a required Android build input, because the trusted
same-origin value is compiled into the debug APK. Its concrete local value is
read from the repository's ignored `.env` by `scripts/android-build.sh` and is
passed to Docker with `--build-arg`. `android/Dockerfile` only declares
`ARG WNH_DEBUG_BASE_URL`; it contains no URL default.
The example `http://10.0.2.2:4010` remains only as unit-test data for origin
matching. It is not a runtime or build default. The ignored `.env` is also
excluded from the root Docker build context.
## Database observations
The final local Compose observation found 2 users, 1 help request, and 7
messages. These are local scenario data; the database is not assumed empty.
The migration `20260718114233` and reverse block lookup index
`blocks_blocked_id_blocker_id_index` were present. An actual `EXPLAIN ANALYZE`
for that reverse lookup selected the index. The table contained no block rows,
so this confirms query shape, not production performance.
Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown:
there is no representative load dataset or target-environment measurement.
The Helm chart therefore does not invent resource limits or an HPA policy.
## Known work before a public production launch
- Configure a real public HTTPS origin and production-sign the Android app.
- Operate PostgreSQL/PostGIS with backups, recovery testing, and the required
availability model.
- Load-test representative data and traffic, then set measured pool, resource,
autoscaling, and action-limit policies.
- Add pagination or bounded loading where real measurements show that request,
chat, moderation, or leaderboard result sets require it.
- Publish jurisdiction-specific emergency contacts, privacy, retention,
prohibited-items, and voluntary-payment guidance after legal review.
- Add OAuth identity verification only if the product chooses to make verified
social accounts a trust signal.
- Design the separate future social-activity mode rather than mixing it with
urgent-help safety and ranking rules.

9
lib/who_need_help.ex Normal file
View File

@ -0,0 +1,9 @@
defmodule WhoNeedHelp do
@moduledoc """
WhoNeedHelp keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@ -0,0 +1,428 @@
defmodule WhoNeedHelp.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
def eligible_for_trust_actions?(%User{
confirmed_at: confirmed_at,
accepted_terms_at: accepted_terms_at,
moderation_status: :active
}) do
not is_nil(confirmed_at) and not is_nil(accepted_terms_at)
end
def eligible_for_trust_actions?(_user), do: false
def eligible_user_id?(user_id) do
case Repo.get(User, user_id) do
%User{} = user -> eligible_for_trust_actions?(user)
nil -> false
end
end
def moderator?(%User{role: role}), do: role in [:moderator, :admin]
def moderator?(_user), do: false
def moderator_authorized?(%User{id: id}) do
case Repo.get(User, id) do
%User{} = user -> moderator?(user)
nil -> false
end
end
def moderator_authorized?(_user), do: false
def admin?(%User{role: :admin}), do: true
def admin?(_user), do: false
def admin_authorized?(%User{id: id}) do
case Repo.get(User, id) do
%User{} = user -> admin?(user)
nil -> false
end
end
def admin_authorized?(_user), do: false
def list_users_for_moderation(%Scope{user: user}) do
if moderator_authorized?(user) do
User
|> order_by([user], asc: user.moderation_status, desc: user.inserted_at)
|> Repo.all()
else
[]
end
end
def moderate_user(%User{} = moderator, user_id, attrs) do
if moderator_authorized?(moderator) do
Repo.transact(fn ->
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
if user.id == moderator.id and attrs["moderation_status"] in ["restricted", "suspended"] do
{:error, :cannot_restrict_self}
else
with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
if user.moderation_status == :suspended do
Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
end
{:ok, user}
end
end
end)
else
{:error, :forbidden}
end
end
def change_user_role(%User{} = admin, user_id, attrs) do
if admin_authorized?(admin) do
Repo.transact(fn ->
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one!()
requested_role = attrs["role"] || attrs[:role]
admin_count =
Repo.aggregate(from(candidate in User, where: candidate.role == :admin), :count)
if user.id == admin.id and requested_role not in [:admin, "admin"] and admin_count == 1 do
{:error, :last_admin}
else
user |> User.role_changeset(attrs) |> Repo.update()
end
end)
else
{:error, :forbidden}
end
end
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
def change_user_registration(%User{} = user, attrs \\ %{}, opts \\ []) do
User.registration_changeset(user, attrs, opts)
end
def change_user_profile(%User{} = user, attrs \\ %{}) do
User.profile_changeset(user, attrs)
end
def update_user_profile(%User{} = user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
def list_social_identities(%User{id: user_id}) do
SocialIdentity
|> where([identity], identity.user_id == ^user_id)
|> order_by([identity], asc: identity.provider, asc: identity.inserted_at)
|> Repo.all()
end
def change_social_identity(%SocialIdentity{} = identity, attrs \\ %{}) do
SocialIdentity.changeset(identity, attrs)
end
def add_social_identity(%User{id: user_id}, attrs) do
%SocialIdentity{user_id: user_id}
|> SocialIdentity.changeset(attrs)
|> Repo.insert()
end
def delete_social_identity(%User{id: user_id}, identity_id) do
case Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
nil -> {:error, :not_found}
identity -> Repo.delete(identity)
end
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `WhoNeedHelp.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `WhoNeedHelp.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user with the given magic link token.
"""
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Logs the user in by magic link.
There are three cases to consider:
1. The user has already confirmed their email. They are logged in
and the magic link is expired.
2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
{user, token} ->
Repo.delete!(token)
{:ok, {user, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given user.
"""
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end

View File

@ -0,0 +1,33 @@
defmodule WhoNeedHelp.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `WhoNeedHelp.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use in authorization checks,
or to ensure specific code paths can only be accessed for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias WhoNeedHelp.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end

View File

@ -0,0 +1,30 @@
defmodule WhoNeedHelp.Accounts.SocialIdentity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "social_identities" do
field :provider, Ecto.Enum, values: [:google, :instagram, :facebook, :telegram, :other]
field :provider_uid, :string
field :profile_url, :string
field :handle, :string
field :verified_at, :utc_datetime
belongs_to :user, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(identity, attrs) do
identity
|> cast(attrs, [:provider, :profile_url, :handle])
|> validate_required([:provider, :profile_url, :user_id])
|> validate_format(:profile_url, ~r/^https?:\/\/[^\s]+$/i,
message: "must be a complete http(s) URL"
)
|> validate_length(:profile_url, max: 500)
|> validate_length(:handle, max: 100)
|> unique_constraint([:provider, :provider_uid])
end
end

View File

@ -0,0 +1,219 @@
defmodule WhoNeedHelp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
field :display_name, :string
field :bio, :string
field :locale, :string, default: "en"
field :location_visibility, Ecto.Enum,
values: [:hidden, :approximate_public, :exact_for_active_match, :exact_public],
default: :approximate_public
field :direct_message_policy, Ecto.Enum,
values: [:everyone, :verified_accounts, :completed_help_users, :nobody],
default: :verified_accounts
field :role, Ecto.Enum, values: [:user, :moderator, :admin], default: :user
field :moderation_status, Ecto.Enum,
values: [:active, :restricted, :suspended],
default: :active
field :moderation_note, :string
field :tip_url, :string
field :accepted_terms_at, :utc_datetime
field :terms_accepted, :boolean, virtual: true, default: false
has_many :social_identities, WhoNeedHelp.Accounts.SocialIdentity
timestamps(type: :utc_datetime)
end
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :display_name, :terms_accepted])
|> validate_required([:display_name])
|> validate_acceptance(:terms_accepted,
message: "you must confirm that you are 18+ and accept the rules"
)
|> validate_length(:display_name, min: 2, max: 80)
|> validate_email(opts)
|> maybe_accept_terms()
end
defp maybe_accept_terms(changeset) do
if get_change(changeset, :terms_accepted) == true do
put_change(changeset, :accepted_terms_at, DateTime.utc_now(:second))
else
changeset
end
end
def profile_changeset(user, attrs) do
user
|> cast(attrs, [
:display_name,
:bio,
:locale,
:location_visibility,
:direct_message_policy,
:tip_url
])
|> validate_required([:display_name, :locale, :location_visibility, :direct_message_policy])
|> validate_length(:display_name, min: 2, max: 80)
|> validate_length(:bio, max: 600)
|> validate_inclusion(:locale, ~w(en uk ru))
|> validate_url(:tip_url)
end
def moderation_changeset(user, attrs) do
user
|> cast(attrs, [:moderation_status, :moderation_note])
|> validate_required([:moderation_status])
|> validate_length(:moderation_note, max: 1_000)
end
def role_changeset(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_required([:role])
end
defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, value ->
case URI.parse(value) do
%URI{scheme: scheme, host: host} when scheme in ["https", "http"] and is_binary(host) ->
[]
_ ->
[{field, "must be a full http(s) URL"}]
end
end)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, WhoNeedHelp.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%WhoNeedHelp.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View File

@ -0,0 +1,84 @@
defmodule WhoNeedHelp.Accounts.UserNotifier do
import Swoosh.Email
alias WhoNeedHelp.Mailer
alias WhoNeedHelp.Accounts.User
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"WhoNeedHelp", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
defp deliver_magic_link_instructions(user, url) do
deliver(user.email, "Log in instructions", """
==============================
Hi #{user.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end

View File

@ -0,0 +1,158 @@
defmodule WhoNeedHelp.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias WhoNeedHelp.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{user, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks whether the token has expired. The context
of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to change the user
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end

View File

@ -0,0 +1,42 @@
defmodule WhoNeedHelp.Application do
# See https://elixir.hexdocs.pm/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
common_children = [
WhoNeedHelpWeb.Telemetry,
WhoNeedHelp.Repo,
{DNSCluster, query: Application.get_env(:who_need_help, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: WhoNeedHelp.PubSub}
]
role_children =
case Application.fetch_env!(:who_need_help, :app_role) do
:web -> [WhoNeedHelpWeb.Presence, WhoNeedHelpWeb.Endpoint]
:worker -> [{Oban, Application.fetch_env!(:who_need_help, Oban)}]
:migrate -> []
end
children = common_children ++ role_children
# See https://elixir.hexdocs.pm/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: WhoNeedHelp.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
if Application.get_env(:who_need_help, :app_role) == :web do
WhoNeedHelpWeb.Endpoint.config_change(changed, removed)
end
:ok
end
end

View File

@ -0,0 +1,297 @@
defmodule WhoNeedHelp.Catalog do
@moduledoc "Data-driven categories, proposals, and community votes."
import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Catalog.{Category, CategoryProposal, CategoryVote}
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
def list_categories do
Category
|> where([c], c.active)
|> order_by([c], asc: c.sort_order, asc: c.slug)
|> Repo.all()
end
def get_category!(id), do: Repo.get!(Category, id)
def list_proposals do
CategoryProposal
|> where([p], p.status == :open)
|> preload([:proposer, :parent, :votes])
|> order_by([p], desc: p.inserted_at)
|> Repo.all()
end
def change_proposal(%CategoryProposal{} = proposal, attrs \\ %{}) do
CategoryProposal.changeset(proposal, attrs)
end
def propose(%Scope{user: user} = scope, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :category_proposal) do
Repo.transact(fn ->
with {:ok, proposal} <-
%CategoryProposal{proposer_id: user.id}
|> CategoryProposal.changeset(attrs)
|> Repo.insert(),
{:ok, _audit} <-
Trust.audit(user.id, "category_proposal.created", "category_proposal", proposal.id) do
{:ok, proposal}
end
end)
end
end
def vote(%Scope{user: user} = scope, proposal_id) do
with {:ok, _limit} <- Trust.authorize_action(scope, :category_vote),
%CategoryProposal{status: :open} <- Repo.get(CategoryProposal, proposal_id) do
%CategoryVote{}
|> CategoryVote.changeset(%{proposal_id: proposal_id, user_id: user.id})
|> Repo.insert()
else
nil -> {:error, :not_found}
%CategoryProposal{} -> {:error, :proposal_closed}
other -> other
end
end
def unvote(%Scope{user: user}, proposal_id) do
{count, _} =
CategoryVote
|> where([v], v.proposal_id == ^proposal_id and v.user_id == ^user.id)
|> Repo.delete_all()
{:ok, count}
end
def seed_defaults do
attrs = %{
slug: "medicine-pickup",
names: %{
"en" => "Medicine pickup",
"uk" => "Доставка ліків",
"ru" => "Доставка лекарств"
},
description: "Pickup and deliver a legal medicine that is already purchased or reserved.",
sort_order: 10,
structured_fields: %{
"fields" => [
%{
"key" => "pickup_status",
"type" => "select",
"required" => true,
"label" => %{
"en" => "Medicine pickup status",
"uk" => "Статус отримання ліків",
"ru" => "Статус получения лекарства"
},
"options" => [
%{
"value" => "reserved",
"label" => %{
"en" => "Reserved",
"uk" => "Зарезервовано",
"ru" => "Зарезервировано"
}
},
%{
"value" => "already_paid",
"label" => %{
"en" => "Already paid",
"uk" => "Вже оплачено",
"ru" => "Уже оплачено"
}
}
]
}
]
}
}
case Repo.get_by(Category, slug: attrs.slug) do
nil ->
%Category{} |> Category.changeset(attrs) |> Repo.insert!()
%Category{structured_fields: fields} = category when fields == %{} ->
category
|> Category.changeset(%{structured_fields: attrs.structured_fields})
|> Repo.update!()
category ->
category
end
end
def structured_fields(%Category{structured_fields: %{"fields" => fields}})
when is_list(fields),
do: fields
def structured_fields(_category), do: []
def validate_structured_data(%Category{} = category, data) when is_map(data) do
fields = structured_fields(category)
allowed = MapSet.new(Enum.map(fields, & &1["key"]))
unknown = data |> Map.keys() |> Enum.reject(&MapSet.member?(allowed, &1))
errors =
if unknown == [],
do: [],
else: ["contains fields that are not configured for this category"]
{clean, errors} =
Enum.reduce(fields, {%{}, errors}, fn field, {clean, errors} ->
key = field["key"]
value = data[key]
cond do
field["required"] == true and value in [nil, ""] ->
{clean, ["#{key} is required" | errors]}
value in [nil, ""] ->
{clean, errors}
valid_structured_value?(field, value) ->
{Map.put(clean, key, value), errors}
true ->
{clean, ["#{key} has an invalid value" | errors]}
end
end)
if errors == [], do: {:ok, clean}, else: {:error, Enum.reverse(errors)}
end
def validate_structured_data(%Category{} = category, nil),
do: validate_structured_data(category, %{})
def validate_structured_data(_category, _data), do: {:error, ["must be an object"]}
def list_proposals_for_moderation(%Scope{user: user}) do
if Accounts.moderator_authorized?(user) do
CategoryProposal
|> order_by([proposal], asc: proposal.status, desc: proposal.inserted_at)
|> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes])
|> Repo.all()
else
[]
end
end
def approve_proposal(%Scope{user: moderator}, proposal_id, category_attrs) do
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
if proposal.status == :open do
category_attrs =
category_attrs
|> Map.new(fn {key, value} -> {to_string(key), value} end)
|> Map.put_new("parent_id", proposal.parent_id)
with {:ok, category} <-
%Category{} |> Category.changeset(category_attrs) |> Repo.insert(),
{:ok, proposal} <-
proposal
|> CategoryProposal.moderation_changeset(%{
status: :approved,
merged_into_id: category.id,
reviewed_by_id: moderator.id,
reviewed_at: DateTime.utc_now(:second),
moderation_note: category_attrs["moderation_note"]
})
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(
moderator.id,
"category_proposal.approved",
"category_proposal",
proposal.id,
%{"category_id" => category.id}
) do
{:ok, %{proposal: proposal, category: category}}
end
else
{:error, :proposal_closed}
end
end)
else
{:error, :forbidden}
end
end
def reject_proposal(%Scope{user: moderator}, proposal_id, note) do
moderate_proposal(moderator, proposal_id, :rejected, nil, note)
end
def merge_proposal(%Scope{user: moderator}, proposal_id, category_id, note) do
with %Category{} <- Repo.get(Category, category_id) do
moderate_proposal(moderator, proposal_id, :merged, category_id, note)
else
nil -> {:error, :not_found}
end
end
defp moderate_proposal(moderator, proposal_id, status, merged_into_id, note) do
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
proposal = locked_proposal(proposal_id)
if proposal.status == :open do
with {:ok, proposal} <-
proposal
|> CategoryProposal.moderation_changeset(%{
status: status,
merged_into_id: merged_into_id,
reviewed_by_id: moderator.id,
reviewed_at: DateTime.utc_now(:second),
moderation_note: note
})
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(
moderator.id,
"category_proposal.#{status}",
"category_proposal",
proposal.id,
%{"merged_into_id" => merged_into_id}
) do
{:ok, proposal}
end
else
{:error, :proposal_closed}
end
end)
else
{:error, :forbidden}
end
end
defp locked_proposal(proposal_id) do
CategoryProposal
|> where([proposal], proposal.id == ^proposal_id)
|> lock("FOR UPDATE")
|> Repo.one!()
end
defp valid_structured_value?(%{"type" => "select", "options" => options}, value)
when is_list(options) do
Enum.any?(options, fn
%{"value" => allowed} -> to_string(allowed) == to_string(value)
allowed -> to_string(allowed) == to_string(value)
end)
end
defp valid_structured_value?(%{"type" => "boolean"}, value),
do: value in [true, false, "true", "false"]
defp valid_structured_value?(%{"type" => "text"} = field, value) when is_binary(value) do
case field["max_length"] do
max when is_integer(max) and max > 0 -> String.length(value) <= max
_ -> true
end
end
defp valid_structured_value?(_field, _value), do: false
end

View File

@ -0,0 +1,120 @@
defmodule WhoNeedHelp.Catalog.Category do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "categories" do
field :slug, :string
field :names, :map, default: %{}
field :description, :string
field :active, :boolean, default: true
field :sort_order, :integer, default: 0
field :structured_fields, :map, default: %{}
belongs_to :parent, __MODULE__
has_many :children, __MODULE__, foreign_key: :parent_id
timestamps(type: :utc_datetime)
end
def changeset(category, attrs) do
category
|> cast(attrs, [
:slug,
:names,
:description,
:active,
:sort_order,
:structured_fields,
:parent_id
])
|> validate_required([:slug, :names])
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|> validate_length(:slug, max: 80)
|> validate_names()
|> validate_structured_fields()
|> unique_constraint(:slug)
end
def name(%__MODULE__{names: names}, locale) do
names[to_string(locale)] || names["en"] || names |> Map.values() |> List.first() || "Help"
end
defp validate_names(changeset) do
validate_change(changeset, :names, fn :names, names ->
if is_map(names) and map_size(names) > 0 and
Enum.all?(names, fn {locale, name} ->
is_binary(locale) and is_binary(name) and String.trim(name) != ""
end) do
[]
else
[names: "must contain at least one non-empty localized name"]
end
end)
end
defp validate_structured_fields(changeset) do
validate_change(changeset, :structured_fields, fn :structured_fields, schema ->
case schema do
%{"fields" => fields} when is_list(fields) ->
keys = Enum.map(fields, &field_key/1)
cond do
not Enum.all?(fields, &valid_field?/1) ->
[structured_fields: "contains an invalid field definition"]
length(keys) != length(Enum.uniq(keys)) ->
[structured_fields: "field keys must be unique"]
true ->
[]
end
schema when schema == %{} ->
[]
_ ->
[structured_fields: "must be an object with a fields array"]
end
end)
end
defp valid_field?(%{"key" => key, "type" => type, "label" => labels} = field)
when is_binary(key) and type in ["text", "select", "boolean"] and is_map(labels) do
valid_key? = Regex.match?(~r/^[a-z][a-z0-9_]*$/, key)
valid_labels? = map_size(labels) > 0 and Enum.all?(labels, &valid_label?/1)
valid_required? = field["required"] in [nil, true, false]
valid_type_options? =
case type do
"select" -> valid_options?(field["options"])
"text" -> field["max_length"] in [nil] or positive_integer?(field["max_length"])
"boolean" -> true
end
valid_key? and valid_labels? and valid_required? and valid_type_options?
end
defp valid_field?(_field), do: false
defp valid_options?(options) when is_list(options) and options != [] do
Enum.all?(options, fn
%{"value" => value, "label" => labels} ->
is_binary(value) and value != "" and is_map(labels) and map_size(labels) > 0 and
Enum.all?(labels, &valid_label?/1)
value ->
is_binary(value) and value != ""
end)
end
defp valid_options?(_options), do: false
defp valid_label?({locale, label}),
do: is_binary(locale) and is_binary(label) and String.trim(label) != ""
defp field_key(%{"key" => key}), do: key
defp field_key(_field), do: nil
defp positive_integer?(value), do: is_integer(value) and value > 0
end

View File

@ -0,0 +1,37 @@
defmodule WhoNeedHelp.Catalog.CategoryProposal do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "category_proposals" do
field :proposed_name, :string
field :reason, :string
field :status, Ecto.Enum, values: [:open, :approved, :rejected, :merged], default: :open
belongs_to :proposer, WhoNeedHelp.Accounts.User
belongs_to :parent, WhoNeedHelp.Catalog.Category
belongs_to :merged_into, WhoNeedHelp.Catalog.Category
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
field :reviewed_at, :utc_datetime
field :moderation_note, :string
has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id
timestamps(type: :utc_datetime)
end
def changeset(proposal, attrs) do
proposal
|> cast(attrs, [:proposed_name, :reason, :parent_id])
|> validate_required([:proposed_name, :reason])
|> validate_length(:proposed_name, min: 2, max: 80)
|> validate_length(:reason, min: 5, max: 500)
end
def moderation_changeset(proposal, attrs) do
proposal
|> cast(attrs, [:status, :merged_into_id, :reviewed_by_id, :reviewed_at, :moderation_note])
|> validate_required([:status, :reviewed_by_id, :reviewed_at])
|> validate_length(:moderation_note, max: 1_000)
end
end

View File

@ -0,0 +1,20 @@
defmodule WhoNeedHelp.Catalog.CategoryVote do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "category_votes" do
belongs_to :proposal, WhoNeedHelp.Catalog.CategoryProposal
belongs_to :user, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(vote, attrs) do
vote
|> cast(attrs, [:proposal_id, :user_id])
|> validate_required([:proposal_id, :user_id])
|> unique_constraint([:proposal_id, :user_id])
end
end

View File

@ -0,0 +1,34 @@
defmodule WhoNeedHelp.CatalogModeration do
@moduledoc """
Produces a PII-free snapshot for an operator-run, advisory Codex review.
Codex never writes to the database. An administrator must review and apply
every recommendation through a separate moderation action.
"""
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
def export_open_proposals do
proposals =
Catalog.list_proposals()
|> Enum.map(fn proposal ->
%{
proposal_id: proposal.id,
proposed_name: proposal.proposed_name,
reason: proposal.reason,
parent_slug: parent_slug(proposal.parent),
community_votes: length(proposal.votes)
}
end)
Jason.encode!(%{
generated_at: DateTime.utc_now(:second),
policy: "Advisory review only. No automatic approval, rejection, merge, or database write.",
proposals: proposals
})
end
defp parent_slug(%Category{slug: slug}), do: slug
defp parent_slug(_), do: nil
end

528
lib/who_need_help/help.ex Normal file
View File

@ -0,0 +1,528 @@
defmodule WhoNeedHelp.Help do
@moduledoc "Authorization-aware request matching and completion workflow."
import Ecto.Query
alias Ecto.Multi
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Catalog
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.Block
@topic "help:requests"
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
def subscribe_request(id),
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")
def notify_request_updated(request_id) do
request = get_request!(request_id)
broadcast({:request_updated, request})
:ok
end
def list_open_requests(%Scope{user: user}, filters \\ %{}) do
now = DateTime.utc_now(:second)
HelpRequest
|> where(
[request],
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
)
)
|> where(
[request],
request.requester_id not in subquery(
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
)
)
|> filter_open_requests(filters)
|> order_by([r], asc: r.expires_at)
|> preload([:category, :requester, assignment: :helper])
|> Repo.all()
end
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
def list_my_requests(%Scope{user: user}) do
HelpRequest
|> where([r], r.requester_id == ^user.id)
|> order_by([r], desc: r.inserted_at)
|> preload([:category, :requester, assignment: :helper])
|> Repo.all()
end
def get_request!(id) do
HelpRequest
|> Repo.get!(id)
|> Repo.preload([
:category,
requester: :social_identities,
assignment: [helper: :social_identities, messages: :sender]
])
end
def get_request(%Scope{user: user} = scope, id) do
case Repo.get(HelpRequest, id) do
nil ->
{:error, :not_found}
request ->
request = get_request!(request.id)
cond do
Accounts.moderator_authorized?(user) ->
{:ok, request}
request.requester_id == user.id ->
{:ok, request}
request.assignment && participant?(scope, request.assignment) ->
{:ok, request}
not is_nil(request.hidden_at) ->
{:error, :not_found}
Trust.blocked_between?(user.id, request.requester_id) ->
{:error, :not_found}
true ->
{:ok, request}
end
end
end
def change_request(%HelpRequest{} = request, attrs \\ %{}) do
request
|> HelpRequest.create_changeset(attrs)
|> validate_structured_data()
end
def create_request(%Scope{user: user}, attrs) do
result =
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :create_request) do
Repo.transact(fn ->
with {:ok, request} <-
%HelpRequest{requester_id: user.id}
|> HelpRequest.create_changeset(attrs)
|> validate_structured_data()
|> Repo.insert(),
{:ok, _audit} <-
Trust.audit(user.id, "request.created", "request", request.id, %{
"urgency" => to_string(request.urgency),
"category_id" => request.category_id
}) do
{:ok, request}
end
end)
end
with {:ok, request} <- result do
broadcast({:request_created, get_request!(request.id)})
{:ok, request}
end
end
def accept_request(%Scope{user: helper}, request_id) do
now = DateTime.utc_now(:second)
code = handover_code(request_id)
result =
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(helper), :accept_request) do
Repo.transact(fn ->
request =
HelpRequest
|> where([r], r.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
cond do
request.requester_id == helper.id ->
{:error, :own_request}
not is_nil(request.hidden_at) ->
{:error, :not_open}
Trust.blocked_between?(request.requester_id, helper.id) ->
{:error, :blocked}
request.status != :open ->
{:error, :not_open}
DateTime.compare(request.expires_at, now) != :gt ->
{:error, :expired}
true ->
with {:ok, assignment} <-
%Assignment{}
|> Assignment.changeset(%{
request_id: request.id,
helper_id: helper.id,
accepted_at: now,
handover_code_hash: code_hash(code)
})
|> Repo.insert(),
{:ok, _request} <-
request |> Ecto.Changeset.change(status: :matched) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(helper.id, "request.accepted", "assignment", assignment.id, %{
"request_id" => request.id
}) do
{:ok, assignment}
end
end
end)
end
case result do
{:ok, assignment} ->
request = get_request!(assignment.request_id)
broadcast({:request_updated, request})
{:ok, Repo.preload(assignment, [:helper, :request])}
other ->
other
end
end
def start_assignment(%Scope{user: user}, assignment_id) do
transition_assignment(user, assignment_id, :start)
end
def confirm_completion(%Scope{user: user}, assignment_id) do
transition_assignment(user, assignment_id, :confirm)
end
def verify_handover(%Scope{user: user}, assignment_id, code) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :verify_handover) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id)
cond do
user.id != assignment.helper_id ->
{:error, :forbidden}
Trust.blocked_between?(assignment.helper_id, request.requester_id) ->
{:error, :blocked}
assignment.status not in [:accepted, :in_progress] or
not is_nil(assignment.handover_verified_at) ->
{:error, :invalid_transition}
not Plug.Crypto.secure_compare(code_hash(code), assignment.handover_code_hash) ->
{:error, :invalid_code}
true ->
now = DateTime.utc_now(:second)
assignment
|> Assignment.changeset(%{handover_verified_at: now})
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "handover.verified", request.id)
end
end)
end
|> after_transition()
end
def cancel_request(%Scope{user: user}, request_id) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :cancel_request) do
Repo.transact(fn ->
request =
HelpRequest
|> where([r], r.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
if request.requester_id == user.id and request.status in [:open, :matched] do
now = DateTime.utc_now(:second)
assignment =
Assignment
|> where([assignment], assignment.request_id == ^request.id)
|> lock("FOR UPDATE")
|> Repo.one()
with {:ok, request} <-
request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|> Repo.update(),
{:ok, _assignment} <- cancel_assignment(assignment),
{:ok, _audit} <-
Trust.audit(user.id, "request.cancelled", "request", request.id) do
{:ok, request}
end
else
{:error, :forbidden}
end
end)
end
|> case do
{:ok, request} ->
WhoNeedHelp.Tracking.cleanup_finished_sessions()
request = get_request!(request.id)
broadcast({:request_updated, request})
{:ok, request}
other ->
other
end
end
def withdraw_assignment(%Scope{user: user}, assignment_id) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), :withdraw_assignment) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id)
if assignment.helper_id == user.id and assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second)
with {:ok, assignment} <-
assignment
|> Assignment.changeset(%{status: :cancelled})
|> Repo.update(),
{:ok, _request} <-
request
|> Ecto.Changeset.change(status: :cancelled, cancelled_at: now)
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(user.id, "assignment.withdrawn", "assignment", assignment.id, %{
"request_id" => request.id
}) do
{:ok, assignment}
end
else
{:error, :invalid_transition}
end
end)
end
|> after_transition()
|> case do
{:ok, _assignment} = result ->
WhoNeedHelp.Tracking.cleanup_finished_sessions()
result
other ->
other
end
end
def participant?(%Scope{user: user}, %Assignment{} = assignment) do
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
user.id in [assignment.helper_id, request.requester_id]
end
def requester?(%Scope{user: user}, %HelpRequest{requester_id: id}), do: user.id == id
def helper?(%Scope{user: user}, %Assignment{helper_id: id}), do: user.id == id
def request_coordinates(%Scope{} = scope, %HelpRequest{} = request) do
participant =
request.requester_id == scope.user.id or
(not is_nil(request.assignment) and participant?(scope, request.assignment))
if participant and request.location_visibility in [:hidden, :exact_for_active_match] do
%Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true}
else
HelpRequest.public_coordinates(request)
end
end
def handover_code(request_id) do
secret = Application.fetch_env!(:who_need_help, :handover_secret)
digest = :crypto.mac(:hmac, :sha256, secret, request_id)
digest
|> :binary.decode_unsigned()
|> rem(1_000_000)
|> Integer.to_string()
|> String.pad_leading(6, "0")
end
defp transition_assignment(user, assignment_id, action) do
with {:ok, _limit} <- Trust.authorize_action(Scope.for_user(user), action) do
Repo.transact(fn ->
assignment = locked_assignment(assignment_id)
request = Repo.get!(HelpRequest, assignment.request_id)
now = DateTime.utc_now(:second)
if Trust.blocked_between?(assignment.helper_id, request.requester_id) do
{:error, :blocked}
else
case {action, assignment.status, user.id} do
{:start, :accepted, helper_id} when helper_id == assignment.helper_id ->
with {:ok, assignment} <-
assignment
|> Assignment.changeset(%{status: :in_progress, started_at: now})
|> Repo.update(),
{:ok, _} <-
request |> Ecto.Changeset.change(status: :in_progress) |> Repo.update(),
{:ok, _audit} <-
Trust.audit(user.id, "assignment.started", "assignment", assignment.id, %{
"request_id" => request.id
}) do
{:ok, assignment}
end
{:confirm, status, user_id} when status in [:accepted, :in_progress] ->
attrs =
cond do
user_id == assignment.helper_id -> %{helper_confirmed_at: now}
user_id == request.requester_id -> %{requester_confirmed_at: now}
true -> nil
end
if attrs do
assignment
|> Assignment.changeset(attrs)
|> maybe_complete(request)
|> audit_assignment_transition(user.id, "assignment.confirmed", request.id)
else
{:error, :forbidden}
end
_ ->
{:error, :invalid_transition}
end
end
end)
end
|> after_transition()
end
defp maybe_complete(changeset, request) do
assignment = Ecto.Changeset.apply_changes(changeset)
if assignment.handover_verified_at && assignment.requester_confirmed_at &&
assignment.helper_confirmed_at do
now = DateTime.utc_now(:second)
Multi.new()
|> Multi.update(
:assignment,
Ecto.Changeset.change(changeset, status: :completed, completed_at: now)
)
|> Multi.update(
:request,
Ecto.Changeset.change(request, status: :completed, completed_at: now)
)
|> Repo.transaction()
|> case do
{:ok, %{assignment: assignment}} -> {:ok, assignment}
{:error, _step, reason, _changes} -> {:error, reason}
end
else
Repo.update(changeset)
end
end
defp locked_assignment(id) do
Assignment
|> where([a], a.id == ^id)
|> lock("FOR UPDATE")
|> Repo.one!()
end
defp code_hash(code), do: :crypto.hash(:sha256, to_string(code))
defp after_transition({:ok, assignment}) do
request = get_request!(assignment.request_id)
broadcast({:request_updated, request})
if assignment.status == :completed, do: Trust.record_completion_signals(assignment)
{:ok, Repo.preload(assignment, [:helper, :request], force: true)}
end
defp after_transition(other), do: other
defp audit_assignment_transition({:ok, assignment}, actor_id, action, request_id) do
with {:ok, _audit} <-
Trust.audit(actor_id, action, "assignment", assignment.id, %{
"request_id" => request_id,
"status" => to_string(assignment.status)
}),
{:ok, _completion_audit} <-
maybe_audit_completion(actor_id, assignment, request_id) do
{:ok, assignment}
end
end
defp audit_assignment_transition(other, _actor_id, _action, _request_id), do: other
defp maybe_audit_completion(actor_id, %Assignment{status: :completed} = assignment, request_id) do
Trust.audit(actor_id, "request.completed", "request", request_id, %{
"assignment_id" => assignment.id
})
end
defp maybe_audit_completion(_actor_id, _assignment, _request_id), do: {:ok, :not_completed}
defp cancel_assignment(nil), do: {:ok, :no_assignment}
defp cancel_assignment(assignment) do
assignment
|> Assignment.changeset(%{status: :cancelled})
|> Repo.update()
end
defp broadcast(event) do
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, @topic, event)
request =
case event do
{_, %HelpRequest{} = request} -> request
end
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
end
defp filter_open_requests(query, filters) do
query
|> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
end
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
defp maybe_filter(query, field, value),
do: where(query, [request], field(request, ^field) == ^value)
defp validate_structured_data(changeset) do
category_id = Ecto.Changeset.get_field(changeset, :category_id)
case category_id && Repo.get(Category, category_id) do
%Category{} = category ->
data = Ecto.Changeset.get_field(changeset, :structured_data)
case Catalog.validate_structured_data(category, data) do
{:ok, clean} ->
Ecto.Changeset.put_change(changeset, :structured_data, clean)
{:error, errors} ->
Ecto.Changeset.add_error(
changeset,
:structured_data,
Enum.join(errors, "; ")
)
end
_ ->
changeset
end
end
end

View File

@ -0,0 +1,49 @@
defmodule WhoNeedHelp.Help.Assignment do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "help_assignments" do
field :status, Ecto.Enum,
values: [:accepted, :in_progress, :completed, :cancelled],
default: :accepted
field :handover_code_hash, :binary
field :handover_verified_at, :utc_datetime
field :requester_confirmed_at, :utc_datetime
field :helper_confirmed_at, :utc_datetime
field :proximity_observed_at, :utc_datetime
field :helper_movement_observed_at, :utc_datetime
field :accepted_at, :utc_datetime
field :started_at, :utc_datetime
field :completed_at, :utc_datetime
belongs_to :request, WhoNeedHelp.Help.HelpRequest
belongs_to :helper, WhoNeedHelp.Accounts.User
has_many :messages, WhoNeedHelp.Messaging.Message
has_many :reviews, WhoNeedHelp.Trust.Review
timestamps(type: :utc_datetime)
end
def changeset(assignment, attrs) do
assignment
|> cast(attrs, [
:request_id,
:helper_id,
:status,
:handover_code_hash,
:handover_verified_at,
:requester_confirmed_at,
:helper_confirmed_at,
:proximity_observed_at,
:helper_movement_observed_at,
:accepted_at,
:started_at,
:completed_at
])
|> validate_required([:request_id, :helper_id, :status, :accepted_at, :handover_code_hash])
|> unique_constraint(:request_id)
end
end

View File

@ -0,0 +1,102 @@
defmodule WhoNeedHelp.Help.HelpRequest do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "help_requests" do
field :title, :string
field :description, :string
field :pickup_instructions, :string
field :structured_data, :map, default: %{}
field :location_label, :string
field :location, Geo.PostGIS.Geometry
field :status, Ecto.Enum,
values: [:open, :matched, :in_progress, :completed, :cancelled, :expired],
default: :open
field :urgency, Ecto.Enum, values: [:now, :today, :scheduled], default: :now
field :location_visibility, Ecto.Enum,
values: [:hidden, :approximate_public, :exact_for_active_match, :exact_public],
default: :approximate_public
field :expires_at, :utc_datetime
field :cancelled_at, :utc_datetime
field :completed_at, :utc_datetime
field :hidden_at, :utc_datetime
field :hidden_reason, :string
belongs_to :requester, WhoNeedHelp.Accounts.User
belongs_to :category, WhoNeedHelp.Catalog.Category
has_one :assignment, WhoNeedHelp.Help.Assignment, foreign_key: :request_id
timestamps(type: :utc_datetime)
end
def create_changeset(request, attrs) do
request
|> cast(attrs, [
:title,
:description,
:pickup_instructions,
:structured_data,
:location_label,
:urgency,
:location_visibility,
:expires_at,
:category_id
])
|> put_location(attrs)
|> validate_required([
:title,
:description,
:location_label,
:location,
:urgency,
:location_visibility,
:expires_at,
:category_id
])
|> validate_length(:title, min: 5, max: 120)
|> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000)
|> validate_expiry()
end
def moderation_changeset(request, attrs) do
request
|> cast(attrs, [:hidden_at, :hidden_reason])
|> validate_length(:hidden_reason, max: 1_000)
end
defp put_location(changeset, attrs) do
lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)),
{lng, ""} <- Float.parse(to_string(lng)),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :location, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else
_ -> add_error(changeset, :location, "select a valid location")
end
end
defp validate_expiry(changeset) do
validate_change(changeset, :expires_at, fn :expires_at, value ->
if DateTime.after?(value, DateTime.utc_now()),
do: [],
else: [expires_at: "must be in the future"]
end)
end
def public_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}} = request) do
case request.location_visibility do
:hidden -> nil
:exact_public -> %{latitude: lat, longitude: lng, exact: true}
_ -> %{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false}
end
end
end

View File

@ -0,0 +1,3 @@
defmodule WhoNeedHelp.Mailer do
use Swoosh.Mailer, otp_app: :who_need_help
end

View File

@ -0,0 +1,64 @@
defmodule WhoNeedHelp.Messaging do
@moduledoc "Durable match chat with PubSub fan-out after commit."
import Ecto.Query
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust
def subscribe(assignment_id) do
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "messages:#{assignment_id}")
end
def list_messages(%Scope{} = scope, %Assignment{} = assignment) do
if Trust.eligible?(scope) and Help.participant?(scope, assignment) and
not blocked_assignment?(scope, assignment) do
Message
|> where([m], m.assignment_id == ^assignment.id)
|> order_by([m], asc: m.inserted_at)
|> preload(:sender)
|> Repo.all()
else
[]
end
end
def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :send_message),
true <- Help.participant?(scope, assignment),
false <- blocked_assignment?(scope, assignment) do
result =
%Message{assignment_id: assignment.id, sender_id: user.id}
|> Message.changeset(attrs)
|> Repo.insert()
with {:ok, message} <- result do
message = Repo.preload(message, :sender)
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"messages:#{assignment.id}",
{:new_message, message}
)
{:ok, message}
end
else
true -> {:error, :blocked}
false -> {:error, :forbidden}
other -> other
end
end
defp blocked_assignment?(%Scope{user: user}, assignment) do
request = Repo.preload(assignment, :request).request
counterpart_id =
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
Trust.blocked_between?(user.id, counterpart_id)
end
end

View File

@ -0,0 +1,22 @@
defmodule WhoNeedHelp.Messaging.Message do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "messages" do
field :body, :string
field :read_at, :utc_datetime
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :sender, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(message, attrs) do
message
|> cast(attrs, [:body])
|> validate_required([:body, :assignment_id, :sender_id])
|> validate_length(:body, min: 1, max: 2_000)
end
end

View File

@ -0,0 +1,5 @@
Postgrex.Types.define(
WhoNeedHelp.PostgrexTypes,
[Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(),
json: Jason
)

View File

@ -0,0 +1,89 @@
defmodule WhoNeedHelp.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :who_need_help
import Ecto.Query
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn repo ->
Ecto.Migrator.run(repo, :up, all: true)
WhoNeedHelp.Catalog.seed_defaults()
end)
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
def bootstrap_admin(email) when is_binary(email) do
WhoNeedHelp.Repo.transact(fn ->
WhoNeedHelp.Repo.query!(
"SELECT pg_advisory_xact_lock(hashtext($1))",
["who_need_help.bootstrap_admin"]
)
admin_count =
WhoNeedHelp.Repo.aggregate(
from(user in WhoNeedHelp.Accounts.User, where: user.role == :admin),
:count
)
cond do
admin_count > 0 ->
{:error, :admin_already_exists}
user = WhoNeedHelp.Repo.get_by(WhoNeedHelp.Accounts.User, email: email) ->
with {:ok, user} <-
user
|> WhoNeedHelp.Accounts.User.role_changeset(%{role: :admin})
|> WhoNeedHelp.Repo.update(),
{:ok, _audit} <-
WhoNeedHelp.Trust.audit(nil, "user.admin_bootstrapped", "user", user.id) do
{:ok, %{id: user.id, email: user.email, role: user.role}}
end
true ->
{:error, :user_not_found}
end
end)
end
def await_migrations do
load_app()
Enum.each(repos(), &await_repo/1)
end
defp await_repo(repo) do
{:ok, ready?, _} =
Ecto.Migrator.with_repo(repo, fn started_repo ->
case Ecto.Migrator.migrations(started_repo) do
[] -> false
migrations -> Enum.all?(migrations, fn {status, _version, _name} -> status == :up end)
end
end)
unless ready? do
Process.sleep(1_000)
await_repo(repo)
end
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
# Many platforms require SSL when connecting to the database
Application.ensure_all_started(:ssl)
Application.ensure_loaded(@app)
end
end

View File

@ -0,0 +1,5 @@
defmodule WhoNeedHelp.Repo do
use Ecto.Repo,
otp_app: :who_need_help,
adapter: Ecto.Adapters.Postgres
end

View File

@ -0,0 +1,289 @@
defmodule WhoNeedHelp.Tracking do
@moduledoc "Consent-driven, foreground-only active match tracking."
import Ecto.Query
alias Ecto.Multi
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust
def subscribe(assignment_id) do
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}")
end
def start_session(
%Scope{user: user} = scope,
%Assignment{} = assignment,
visibility \\ :active_match
) do
with {:ok, _limit} <- Trust.authorize_action(scope, :start_tracking),
true <- Help.participant?(scope, assignment),
true <- assignment.status in [:accepted, :in_progress] do
now = DateTime.utc_now(:second)
%TrackingSession{}
|> TrackingSession.changeset(%{
assignment_id: assignment.id,
user_id: user.id,
visibility: visibility,
started_at: now
})
|> Repo.insert(
on_conflict: [set: [active: true, ended_at: nil, started_at: now, visibility: visibility]],
conflict_target: {:unsafe_fragment, "(assignment_id, user_id) WHERE active"}
)
else
false -> {:error, :forbidden}
other -> other
end
end
def update_position(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
with {:ok, _limit} <- Trust.authorize_action(scope, :tracking_position),
true <- Help.participant?(scope, assignment) do
result =
Repo.transact(fn ->
session =
TrackingSession
|> where(
[session],
session.assignment_id == ^assignment.id and session.user_id == ^user.id and
session.active
)
|> lock("FOR UPDATE")
|> Repo.one()
if session do
persist_position(session, assignment, user, attrs)
else
{:error, :tracking_not_active}
end
end)
with {:ok, position} <- result do
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment.id}",
{:position_updated, user.id, public_position(position)}
)
{:ok, position}
end
else
false -> {:error, :forbidden}
other -> other
end
end
def stop_session(%Scope{user: user} = scope, %Assignment{} = assignment) do
if Help.participant?(scope, assignment) do
case Repo.get_by(TrackingSession,
assignment_id: assignment.id,
user_id: user.id,
active: true
) do
nil ->
{:ok, :already_stopped}
session ->
now = DateTime.utc_now(:second)
Multi.new()
|> Multi.delete_all(:positions, where(Position, tracking_session_id: ^session.id))
|> Multi.update(
:session,
TrackingSession.changeset(session, %{active: false, ended_at: now})
)
|> Repo.transaction()
|> case do
{:ok, _} ->
Phoenix.PubSub.broadcast(
WhoNeedHelp.PubSub,
"tracking:#{assignment.id}",
{:tracking_stopped, user.id}
)
{:ok, :stopped}
{:error, _step, reason, _} ->
{:error, reason}
end
end
else
{:error, :forbidden}
end
end
def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do
if Trust.eligible?(scope) and Help.participant?(scope, assignment) do
TrackingSession
|> where([s], s.assignment_id == ^assignment.id and s.active)
|> join(:inner, [s], p in assoc(s, :position))
|> select([s, p], {s.user_id, p})
|> Repo.all()
|> Map.new(fn {user_id, position} -> {user_id, public_position(position)} end)
else
%{}
end
end
def cleanup_finished_sessions do
now = DateTime.utc_now(:second)
session_ids =
TrackingSession
|> join(:inner, [session], assignment in Assignment,
on: assignment.id == session.assignment_id
)
|> where(
[session, assignment],
session.active and assignment.status in [:completed, :cancelled]
)
|> select([session], session.id)
Repo.transaction(fn ->
{deleted_positions, _} =
Position
|> where([position], position.tracking_session_id in subquery(session_ids))
|> Repo.delete_all()
{ended_sessions, _} =
TrackingSession
|> where([session], session.id in subquery(session_ids))
|> Repo.update_all(set: [active: false, ended_at: now, updated_at: now])
%{positions_deleted: deleted_positions, sessions_ended: ended_sessions}
end)
end
defp public_position(%Position{
position: %Geo.Point{coordinates: {lng, lat}},
accuracy_meters: accuracy,
captured_at: captured_at
}) do
%{latitude: lat, longitude: lng, accuracy: accuracy, captured_at: captured_at}
end
defp persist_position(session, assignment, user, attrs) do
previous =
Position
|> where([position], position.tracking_session_id == ^session.id)
|> lock("FOR UPDATE")
|> Repo.one()
now = DateTime.utc_now(:second)
attrs = Map.put(attrs, "captured_at", now)
with {:ok, position} <-
%Position{tracking_session_id: session.id}
|> Position.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace, [:position, :accuracy_meters, :captured_at, :updated_at]},
conflict_target: :tracking_session_id,
returning: true
) do
movement = movement_evidence(previous, position)
movement_at = session.movement_observed_at || if(movement > 0, do: now)
{:ok, session} =
session
|> TrackingSession.changeset(%{
sample_count: session.sample_count + 1,
distance_meters: session.distance_meters + movement,
movement_observed_at: movement_at
})
|> Repo.update()
assignment =
Assignment
|> where([candidate], candidate.id == ^assignment.id)
|> lock("FOR UPDATE")
|> Repo.one!()
assignment =
maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now)
maybe_mark_proximity(assignment, session, position, now)
{:ok, position}
end
end
defp maybe_mark_helper_movement(assignment, user_id, movement_observed_at, now) do
if assignment.helper_id == user_id and not is_nil(movement_observed_at) and
is_nil(assignment.helper_movement_observed_at) do
assignment
|> Assignment.changeset(%{helper_movement_observed_at: now})
|> Repo.update!()
else
assignment
end
end
defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed} = assignment, _, _, _)
when not is_nil(observed),
do: assignment
defp maybe_mark_proximity(assignment, session, position, now) do
counterpart_positions =
TrackingSession
|> where(
[candidate],
candidate.assignment_id == ^assignment.id and candidate.active and
candidate.user_id != ^session.user_id
)
|> join(:inner, [candidate], point in assoc(candidate, :position))
|> select([_candidate, point], point)
|> Repo.all()
if Enum.any?(counterpart_positions, &accuracy_envelopes_overlap?(&1, position)) do
assignment
|> Assignment.changeset(%{proximity_observed_at: now})
|> Repo.update!()
else
assignment
end
end
defp movement_evidence(nil, _current), do: 0.0
defp movement_evidence(previous, current) do
with previous_accuracy when is_number(previous_accuracy) <- previous.accuracy_meters,
current_accuracy when is_number(current_accuracy) <- current.accuracy_meters do
max(distance(previous, current) - previous_accuracy - current_accuracy, 0.0)
else
_ -> 0.0
end
end
defp accuracy_envelopes_overlap?(first, second) do
with first_accuracy when is_number(first_accuracy) <- first.accuracy_meters,
second_accuracy when is_number(second_accuracy) <- second.accuracy_meters do
distance(first, second) <= first_accuracy + second_accuracy
else
_ -> false
end
end
defp distance(
%Position{position: %Geo.Point{coordinates: {first_lng, first_lat}}},
%Position{position: %Geo.Point{coordinates: {second_lng, second_lat}}}
) do
earth_radius_meters = 6_371_008.8
latitude_delta = radians(second_lat - first_lat)
longitude_delta = radians(second_lng - first_lng)
a =
:math.sin(latitude_delta / 2) ** 2 +
:math.cos(radians(first_lat)) * :math.cos(radians(second_lat)) *
:math.sin(longitude_delta / 2) ** 2
a = a |> max(0.0) |> min(1.0)
earth_radius_meters * 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
end
defp radians(degrees), do: degrees * :math.pi() / 180
end

View File

@ -0,0 +1,37 @@
defmodule WhoNeedHelp.Tracking.Position do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "tracking_positions" do
field :position, Geo.PostGIS.Geometry
field :accuracy_meters, :float
field :captured_at, :utc_datetime
belongs_to :tracking_session, WhoNeedHelp.Tracking.TrackingSession
timestamps(type: :utc_datetime)
end
def changeset(position, attrs) do
position
|> cast(attrs, [:accuracy_meters, :captured_at])
|> put_position(attrs)
|> validate_required([:tracking_session_id, :position, :captured_at])
|> validate_number(:accuracy_meters, greater_than_or_equal_to: 0)
|> unique_constraint(:tracking_session_id)
end
defp put_position(changeset, attrs) do
lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude]
with {lat, ""} <- Float.parse(to_string(lat)),
{lng, ""} <- Float.parse(to_string(lng)),
true <- lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180 do
put_change(changeset, :position, %Geo.Point{coordinates: {lng, lat}, srid: 4326})
else
_ -> add_error(changeset, :position, "is invalid")
end
end
end

View File

@ -0,0 +1,46 @@
defmodule WhoNeedHelp.Tracking.TrackingSession do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "tracking_sessions" do
field :active, :boolean, default: true
field :visibility, Ecto.Enum,
values: [:active_match, :public],
default: :active_match
field :started_at, :utc_datetime
field :ended_at, :utc_datetime
field :distance_meters, :float, default: 0.0
field :sample_count, :integer, default: 0
field :movement_observed_at, :utc_datetime
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :user, WhoNeedHelp.Accounts.User
has_one :position, WhoNeedHelp.Tracking.Position
timestamps(type: :utc_datetime)
end
def changeset(session, attrs) do
session
|> cast(attrs, [
:assignment_id,
:user_id,
:visibility,
:active,
:started_at,
:ended_at,
:distance_meters,
:sample_count,
:movement_observed_at
])
|> validate_required([:assignment_id, :user_id, :visibility, :started_at])
|> validate_number(:distance_meters, greater_than_or_equal_to: 0)
|> validate_number(:sample_count, greater_than_or_equal_to: 0)
|> unique_constraint([:assignment_id, :user_id],
name: :tracking_sessions_one_active_per_user
)
end
end

695
lib/who_need_help/trust.ex Normal file
View File

@ -0,0 +1,695 @@
defmodule WhoNeedHelp.Trust do
@moduledoc "Reviews, reports, blocks, reputation, abuse signals, and auditable moderation."
import Ecto.Query
alias WhoNeedHelp.Accounts
alias WhoNeedHelp.Accounts.{Scope, User}
alias WhoNeedHelp.Help
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{
AbuseSignal,
AuditEvent,
Block,
RateLimiter,
Report,
Review
}
def authorize_action(%Scope{user: %User{} = user}, action) do
user = Repo.get(User, user.id)
cond do
not Accounts.eligible_for_trust_actions?(user) ->
{:error, :account_not_eligible}
true ->
case RateLimiter.check(action, user.id) do
{:error, :rate_limited} = error ->
create_velocity_signal_once(user.id, action)
error
result ->
result
end
end
end
def authorize_action(_, _action), do: {:error, :account_not_eligible}
def eligible?(%Scope{user: %User{id: id}}), do: Accounts.eligible_user_id?(id)
def eligible?(_scope), do: false
def submit_review(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do
attrs = stringify_keys(attrs)
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
with {:ok, _limit} <- authorize_action(scope, :review),
true <- assignment.status == :completed,
true <- user.id in [assignment.helper_id, request.requester_id] do
reviewee_id =
if user.id == assignment.helper_id, do: request.requester_id, else: assignment.helper_id
Repo.transact(fn ->
with {:ok, review} <-
%Review{}
|> Review.changeset(
Map.merge(attrs, %{
"assignment_id" => assignment.id,
"reviewer_id" => user.id,
"reviewee_id" => reviewee_id
})
)
|> Repo.insert(),
reviews <- Repo.all(from r in Review, where: r.assignment_id == ^assignment.id),
true <- length(reviews) <= 2 do
if length(reviews) == 2 do
now = DateTime.utc_now(:second)
Repo.update_all(from(r in Review, where: r.assignment_id == ^assignment.id),
set: [revealed_at: now]
)
end
audit(user.id, "review.submitted", "assignment", assignment.id, %{
"revealed" => length(reviews) == 2
})
{:ok, review}
else
false -> {:error, :invalid_review_count}
other -> other
end
end)
else
false -> {:error, :forbidden}
other -> other
end
end
def visible_reviews(user_id) do
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> order_by([review], desc: review.inserted_at)
|> preload(:reviewer)
|> Repo.all()
end
def reputation(user_id) do
rows = completed_rows() |> Enum.filter(&participant?(&1, user_id))
counterparts = Enum.map(rows, &counterpart(&1, user_id))
ratings = ratings(user_id)
%{
completed: length(rows),
unique_people: counterparts |> Enum.uniq() |> length(),
verified_handovers: Enum.count(rows, &(not is_nil(&1.handover_verified_at))),
location_supported:
Enum.count(
rows,
&(not is_nil(&1.proximity_observed_at) and movement_for_user?(&1, user_id))
),
rating: average(ratings)
}
end
def leaderboard do
ratings_by_user =
Review
|> where([review], not is_nil(review.revealed_at))
|> group_by([review], review.reviewee_id)
|> select([review], {review.reviewee_id, avg(review.rating)})
|> Repo.all()
|> Map.new()
completed_rows()
|> Enum.group_by(& &1.helper_id)
|> Enum.map(fn {helper_id, rows} ->
helper = rows |> hd() |> Map.fetch!(:helper)
supported_people =
rows
|> Enum.filter(
&(not is_nil(&1.proximity_observed_at) and
not is_nil(&1.helper_movement_observed_at))
)
|> Enum.map(& &1.requester_id)
|> Enum.uniq()
|> length()
verified_people =
rows
|> Enum.filter(&(not is_nil(&1.handover_verified_at)))
|> Enum.map(& &1.requester_id)
|> Enum.uniq()
|> length()
%{
user: helper,
completed: length(rows),
unique_people: rows |> Enum.map(& &1.requester_id) |> Enum.uniq() |> length(),
location_supported_people: supported_people,
verified_people: verified_people,
rating: ratings_by_user |> Map.get(helper_id) |> decimal_average()
}
end)
|> Enum.sort_by(fn entry ->
{
-entry.location_supported_people,
-entry.verified_people,
-entry.unique_people,
-entry.completed,
String.downcase(entry.user.display_name || "")
}
end)
end
def report(%Scope{user: user} = scope, attrs) do
attrs = stringify_keys(attrs)
with {:ok, _limit} <- authorize_action(scope, :report),
:ok <- authorize_report_target(scope, attrs) do
Repo.transact(fn ->
with {:ok, report} <-
%Report{reporter_id: user.id}
|> Report.changeset(attrs)
|> Repo.insert() do
audit(user.id, "report.created", "report", report.id, %{
"reason" => to_string(report.reason)
})
{:ok, report}
end
end)
end
end
def list_reports(%Scope{user: user}, status \\ nil) do
if Accounts.moderator_authorized?(user) do
Report
|> maybe_status(status)
|> order_by([report], asc: report.status, desc: report.inserted_at)
|> preload([
:reporter,
:reviewed_by,
request: :requester,
assignment: :helper,
message: :sender
])
|> Repo.all()
else
[]
end
end
def moderate_report(%Scope{user: moderator}, report_id, attrs) do
if Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
Repo.transact(fn ->
report =
Report |> where([report], report.id == ^report_id) |> lock("FOR UPDATE") |> Repo.one!()
with {:ok, report} <- report |> Report.moderation_changeset(attrs) |> Repo.update() do
audit(moderator.id, "report.moderated", "report", report.id, %{
"status" => to_string(report.status)
})
{:ok, report}
end
end)
else
{:error, :forbidden}
end
end
def report_evidence(%Scope{user: moderator}, report_id) do
if Accounts.moderator_authorized?(moderator) do
report =
Report
|> Repo.get(report_id)
|> Repo.preload([:reporter, :request, :assignment, message: :assignment])
if report do
assignment_id =
cond do
report.assignment_id -> report.assignment_id
report.message -> report.message.assignment_id
true -> nil
end
messages =
if assignment_id do
Message
|> where([message], message.assignment_id == ^assignment_id)
|> order_by([message], asc: message.inserted_at)
|> preload(:sender)
|> Repo.all()
else
[]
end
with {:ok, _audit} <-
audit(moderator.id, "report.evidence_viewed", "report", report.id, %{
"assignment_id" => assignment_id,
"message_count" => length(messages)
}) do
{:ok, %{report: report, messages: messages}}
end
else
{:error, :not_found}
end
else
{:error, :forbidden}
end
end
def block(%Scope{user: user} = scope, blocked_id) do
with {:ok, _limit} <- authorize_action(scope, :block),
%User{} <- Repo.get(User, blocked_id) do
Repo.transact(fn ->
with {:ok, block} <-
%Block{}
|> Block.changeset(%{blocker_id: user.id, blocked_id: blocked_id})
|> Repo.insert() do
audit(user.id, "user.blocked", "user", blocked_id)
clear_pair_tracking(user.id, blocked_id)
{:ok, block}
end
end)
else
nil -> {:error, :not_found}
other -> other
end
end
def unblock(%Scope{user: user}, blocked_id) do
case Repo.get_by(Block, blocker_id: user.id, blocked_id: blocked_id) do
nil ->
{:ok, :already_unblocked}
block ->
Repo.transact(fn ->
with {:ok, _block} <- Repo.delete(block) do
audit(user.id, "user.unblocked", "user", blocked_id)
{:ok, :unblocked}
end
end)
end
end
def list_blocks(%Scope{user: user}) do
Block
|> where([block], block.blocker_id == ^user.id)
|> order_by([block], desc: block.inserted_at)
|> preload(:blocked)
|> Repo.all()
end
def blocked_between?(first_user_id, second_user_id) do
Block
|> where(
[block],
(block.blocker_id == ^first_user_id and block.blocked_id == ^second_user_id) or
(block.blocker_id == ^second_user_id and block.blocked_id == ^first_user_id)
)
|> Repo.exists?()
end
def blocked_by?(blocker_id, blocked_id) do
Repo.exists?(
from block in Block,
where: block.blocker_id == ^blocker_id and block.blocked_id == ^blocked_id
)
end
def record_completion_signals(%Assignment{} = assignment) do
request =
case assignment.request do
%HelpRequest{} = request -> request
_ -> Repo.get!(HelpRequest, assignment.request_id)
end
repeated_pair? =
Assignment
|> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id)
|> where(
[candidate, req],
candidate.status == :completed and candidate.id != ^assignment.id and
candidate.helper_id == ^assignment.helper_id and
req.requester_id == ^request.requester_id
)
|> Repo.exists?()
if repeated_pair? do
create_signal_once(:repeated_pair, assignment.helper_id, assignment.id, %{
"counterpart_id" => request.requester_id
})
end
reciprocal? =
Assignment
|> join(:inner, [candidate], req in HelpRequest, on: req.id == candidate.request_id)
|> where(
[candidate, req],
candidate.status == :completed and candidate.helper_id == ^request.requester_id and
req.requester_id == ^assignment.helper_id
)
|> Repo.exists?()
if reciprocal? do
create_signal_once(:reciprocal_ring, assignment.helper_id, assignment.id, %{
"counterpart_id" => request.requester_id,
"factual_signal_only" => true
})
end
if is_nil(assignment.proximity_observed_at) do
create_signal_once(
:handover_without_location_evidence,
assignment.helper_id,
assignment.id,
%{"tracking_optional" => true}
)
end
if is_nil(assignment.helper_movement_observed_at) do
create_signal_once(:location_without_movement, assignment.helper_id, assignment.id, %{
"tracking_optional" => true
})
end
:ok
end
def list_abuse_signals(%Scope{user: user}, status \\ :open) do
if Accounts.moderator_authorized?(user) do
AbuseSignal
|> where([signal], signal.status == ^status)
|> order_by([signal], desc: signal.inserted_at)
|> preload([:subject, :assignment, :reviewed_by])
|> Repo.all()
else
[]
end
end
def moderate_signal(%Scope{user: moderator}, signal_id, attrs) do
if Accounts.moderator_authorized?(moderator) do
attrs =
attrs
|> stringify_keys()
|> Map.merge(%{
"reviewed_at" => DateTime.utc_now(:second),
"reviewed_by_id" => moderator.id
})
Repo.transact(fn ->
signal =
AbuseSignal
|> where([signal], signal.id == ^signal_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, signal} <- signal |> AbuseSignal.moderation_changeset(attrs) |> Repo.update() do
audit(moderator.id, "abuse_signal.moderated", "abuse_signal", signal.id, %{
"status" => to_string(signal.status)
})
{:ok, signal}
end
end)
else
{:error, :forbidden}
end
end
def hide_request(%Scope{user: moderator}, request_id, reason) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{
hidden_at: DateTime.utc_now(:second),
hidden_reason: reason
})
|> Repo.update() do
audit(moderator.id, "request.hidden", "request", request.id, %{"reason" => reason})
{:ok, request}
end
end)
else
{:error, :forbidden}
end
with {:ok, request} <- result do
Help.notify_request_updated(request.id)
{:ok, request}
end
end
def restore_request(%Scope{user: moderator}, request_id) do
result =
if Accounts.moderator_authorized?(moderator) do
Repo.transact(fn ->
request =
HelpRequest
|> where([request], request.id == ^request_id)
|> lock("FOR UPDATE")
|> Repo.one!()
with {:ok, request} <-
request
|> HelpRequest.moderation_changeset(%{hidden_at: nil, hidden_reason: nil})
|> Repo.update() do
audit(moderator.id, "request.restored", "request", request.id)
{:ok, request}
end
end)
else
{:error, :forbidden}
end
with {:ok, request} <- result do
Help.notify_request_updated(request.id)
{:ok, request}
end
end
def moderate_user(%Scope{user: moderator}, user_id, attrs) do
Repo.transact(fn ->
with {:ok, user} <- Accounts.moderate_user(moderator, user_id, attrs),
{:ok, _audit} <-
audit(moderator.id, "user.moderated", "user", user.id, %{
"moderation_status" => to_string(user.moderation_status)
}) do
{:ok, user}
end
end)
end
def moderate_role(%Scope{user: admin}, user_id, attrs) do
Repo.transact(fn ->
with {:ok, user} <- Accounts.change_user_role(admin, user_id, attrs),
{:ok, _audit} <-
audit(admin.id, "user.role_changed", "user", user.id, %{
"role" => to_string(user.role)
}) do
{:ok, user}
end
end)
end
def audit(actor_id, action, target_type, target_id, metadata \\ %{}) do
%AuditEvent{}
|> AuditEvent.changeset(%{
actor_id: actor_id,
action: action,
target_type: target_type,
target_id: target_id,
metadata: metadata
})
|> Repo.insert()
end
defp completed_rows do
Assignment
|> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id)
|> join(:inner, [assignment, _request], helper in User, on: helper.id == assignment.helper_id)
|> where([assignment], assignment.status == :completed)
|> select([assignment, request, helper], %{
id: assignment.id,
helper_id: assignment.helper_id,
requester_id: request.requester_id,
handover_verified_at: assignment.handover_verified_at,
proximity_observed_at: assignment.proximity_observed_at,
helper_movement_observed_at: assignment.helper_movement_observed_at,
helper: helper
})
|> Repo.all()
end
defp participant?(row, user_id), do: row.helper_id == user_id or row.requester_id == user_id
defp counterpart(row, user_id),
do: if(row.helper_id == user_id, do: row.requester_id, else: row.helper_id)
defp movement_for_user?(row, user_id) do
if row.helper_id == user_id, do: not is_nil(row.helper_movement_observed_at), else: true
end
defp ratings(user_id) do
Review
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|> select([review], review.rating)
|> Repo.all()
end
defp average([]), do: nil
defp average(values), do: Float.round(Enum.sum(values) / length(values), 1)
defp decimal_average(nil), do: nil
defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1)
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
when is_binary(request_id) do
case Repo.get(HelpRequest, request_id) do
%HelpRequest{requester_id: requester_id} when requester_id != user.id -> :ok
%HelpRequest{} -> {:error, :cannot_report_self}
nil -> {:error, :not_found}
end
end
defp authorize_report_target(scope, %{"assignment_id" => assignment_id})
when is_binary(assignment_id) do
case Repo.get(Assignment, assignment_id) do
%Assignment{} = assignment ->
if Help.participant?(scope, assignment), do: :ok, else: {:error, :forbidden}
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(scope, %{"message_id" => message_id}) when is_binary(message_id) do
case Message |> Repo.get(message_id) |> Repo.preload(:assignment) do
%Message{sender_id: sender_id, assignment: assignment} ->
cond do
sender_id == scope.user.id -> {:error, :cannot_report_self}
Help.participant?(scope, assignment) -> :ok
true -> {:error, :forbidden}
end
nil ->
{:error, :not_found}
end
end
defp authorize_report_target(_scope, _attrs), do: {:error, :invalid_target}
defp create_signal_once(kind, subject_id, assignment_id, metadata) do
query =
from signal in AbuseSignal,
where:
signal.kind == ^kind and signal.subject_id == ^subject_id and
signal.assignment_id == ^assignment_id
if Repo.exists?(query) do
{:ok, :already_recorded}
else
%AbuseSignal{}
|> AbuseSignal.changeset(%{
kind: kind,
subject_id: subject_id,
assignment_id: assignment_id,
metadata: metadata
})
|> Repo.insert()
end
end
defp create_velocity_signal_once(subject_id, action) do
query =
from signal in AbuseSignal,
where:
signal.kind == :velocity and signal.subject_id == ^subject_id and
signal.status == :open
case Repo.one(query) do
nil ->
%AbuseSignal{}
|> AbuseSignal.changeset(%{
kind: :velocity,
subject_id: subject_id,
metadata: %{"action" => to_string(action)}
})
|> Repo.insert()
signal ->
signal
|> Ecto.Changeset.change(
metadata: Map.put(signal.metadata, "latest_action", to_string(action))
)
|> Repo.update()
end
end
defp maybe_status(query, nil), do: query
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
defp clear_pair_tracking(first_user_id, second_user_id) do
assignment_ids =
Assignment
|> join(:inner, [assignment], request in HelpRequest,
on: request.id == assignment.request_id
)
|> where(
[assignment, request],
(assignment.helper_id == ^first_user_id and request.requester_id == ^second_user_id) or
(assignment.helper_id == ^second_user_id and request.requester_id == ^first_user_id)
)
|> where([assignment], assignment.status in [:accepted, :in_progress])
|> select([assignment], assignment.id)
session_ids =
TrackingSession
|> where([session], session.active and session.assignment_id in subquery(assignment_ids))
|> select([session], session.id)
Repo.delete_all(
from position in Position, where: position.tracking_session_id in subquery(session_ids)
)
Repo.update_all(
from(session in TrackingSession, where: session.id in subquery(session_ids)),
set: [active: false, ended_at: DateTime.utc_now(:second)]
)
:ok
end
defp stringify_keys(attrs) do
Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
end
end

View File

@ -0,0 +1,44 @@
defmodule WhoNeedHelp.Trust.AbuseSignal do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "abuse_signals" do
field :kind, Ecto.Enum,
values: [
:velocity,
:repeated_pair,
:handover_without_location_evidence,
:location_without_movement,
:reciprocal_ring,
:manual
]
field :status, Ecto.Enum, values: [:open, :reviewed, :dismissed], default: :open
field :metadata, :map, default: %{}
field :reviewed_at, :utc_datetime
field :review_note, :string
belongs_to :subject, WhoNeedHelp.Accounts.User
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(signal, attrs) do
signal
|> cast(attrs, [:kind, :status, :metadata, :subject_id, :assignment_id])
|> validate_required([:kind, :subject_id])
|> unique_constraint([:kind, :subject_id, :assignment_id],
name: :abuse_signals_one_kind_per_assignment
)
end
def moderation_changeset(signal, attrs) do
signal
|> cast(attrs, [:status, :reviewed_at, :review_note, :reviewed_by_id])
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|> validate_length(:review_note, max: 1_000)
end
end

View File

@ -0,0 +1,22 @@
defmodule WhoNeedHelp.Trust.AuditEvent do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "audit_events" do
field :action, :string
field :target_type, :string
field :target_id, :binary_id
field :metadata, :map, default: %{}
belongs_to :actor, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(event, attrs) do
event
|> cast(attrs, [:action, :target_type, :target_id, :metadata, :actor_id])
|> validate_required([:action, :target_type])
end
end

View File

@ -0,0 +1,21 @@
defmodule WhoNeedHelp.Trust.Block do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "blocks" do
belongs_to :blocker, WhoNeedHelp.Accounts.User
belongs_to :blocked, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(block, attrs) do
block
|> cast(attrs, [:blocker_id, :blocked_id])
|> validate_required([:blocker_id, :blocked_id])
|> unique_constraint([:blocker_id, :blocked_id])
|> check_constraint(:blocked_id, name: :block_cannot_target_self)
end
end

View File

@ -0,0 +1,25 @@
defmodule WhoNeedHelp.Trust.RateLimitBucket do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rate_limit_buckets" do
field :action, :string
field :scope_hash, :binary
field :window_started_at, :utc_datetime
field :count, :integer, default: 0
field :expires_at, :utc_datetime
timestamps(type: :utc_datetime)
end
def changeset(bucket, attrs) do
bucket
|> cast(attrs, [:action, :scope_hash, :window_started_at, :count, :expires_at])
|> validate_required([:action, :scope_hash, :window_started_at, :count, :expires_at])
|> validate_length(:action, min: 1, max: 80)
|> validate_number(:count, greater_than_or_equal_to: 0)
|> unique_constraint([:action, :scope_hash, :window_started_at])
end
end

View File

@ -0,0 +1,84 @@
defmodule WhoNeedHelp.Trust.RateLimiter do
@moduledoc """
Shared PostgreSQL-backed action limits.
Policies are opt-in and supplied as a map whose values contain positive
`limit` and `window_seconds` integers. No product thresholds are assumed.
"""
import Ecto.Query
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.RateLimitBucket
def check(action, scope) when is_atom(action), do: check(Atom.to_string(action), scope)
def check(action, scope) when is_binary(action) do
case policy(action) do
nil ->
{:ok, :not_configured}
%{limit: limit, window_seconds: window_seconds} ->
increment(action, scope, limit, window_seconds)
end
end
def prune_expired do
now = DateTime.utc_now(:second)
Repo.delete_all(from bucket in RateLimitBucket, where: bucket.expires_at <= ^now)
end
defp policy(action) do
policies = Application.get_env(:who_need_help, :rate_limit_policies, %{})
raw = Map.get(policies, action) || Map.get(policies, String.to_existing_atom(action))
case raw do
%{limit: limit, window_seconds: window}
when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 ->
%{limit: limit, window_seconds: window}
%{"limit" => limit, "window_seconds" => window}
when is_integer(limit) and limit > 0 and is_integer(window) and window > 0 ->
%{limit: limit, window_seconds: window}
_ ->
nil
end
rescue
ArgumentError -> nil
end
defp increment(action, scope, limit, window_seconds) do
now = DateTime.utc_now(:second)
unix = DateTime.to_unix(now)
window_start = (div(unix, window_seconds) * window_seconds) |> DateTime.from_unix!()
expires_at = DateTime.add(window_start, window_seconds, :second)
scope_hash = :crypto.hash(:sha256, to_string(scope))
{_count, [bucket]} =
Repo.insert_all(
RateLimitBucket,
[
%{
id: Ecto.UUID.generate(),
action: action,
scope_hash: scope_hash,
window_started_at: window_start,
count: 1,
expires_at: expires_at,
inserted_at: now,
updated_at: now
}
],
on_conflict: [inc: [count: 1], set: [expires_at: expires_at, updated_at: now]],
conflict_target: [:action, :scope_hash, :window_started_at],
returning: [:count]
)
if bucket.count <= limit do
{:ok, %{count: bucket.count, limit: limit, resets_at: expires_at}}
else
{:error, :rate_limited}
end
end
end

View File

@ -0,0 +1,57 @@
defmodule WhoNeedHelp.Trust.Report do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "reports" do
field :reason, Ecto.Enum,
values: [
:dangerous_request,
:harassment,
:fraud,
:prohibited_item,
:spam,
:impersonation,
:other
]
field :details, :string
field :status, Ecto.Enum, values: [:open, :reviewing, :resolved, :dismissed], default: :open
field :resolution_note, :string
field :reviewed_at, :utc_datetime
belongs_to :reporter, WhoNeedHelp.Accounts.User
belongs_to :reviewed_by, WhoNeedHelp.Accounts.User
belongs_to :request, WhoNeedHelp.Help.HelpRequest
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :message, WhoNeedHelp.Messaging.Message
timestamps(type: :utc_datetime)
end
def changeset(report, attrs) do
report
|> cast(attrs, [:reason, :details, :request_id, :assignment_id, :message_id])
|> validate_required([:reason, :details, :reporter_id])
|> validate_length(:details, min: 5, max: 2_000)
|> validate_single_target()
|> check_constraint(:request_id, name: :report_exactly_one_target)
end
def moderation_changeset(report, attrs) do
report
|> cast(attrs, [:status, :resolution_note, :reviewed_at, :reviewed_by_id])
|> validate_required([:status, :reviewed_at, :reviewed_by_id])
|> validate_length(:resolution_note, max: 2_000)
end
defp validate_single_target(changeset) do
targets =
[:request_id, :assignment_id, :message_id]
|> Enum.count(&(not is_nil(get_field(changeset, &1))))
if targets == 1,
do: changeset,
else: add_error(changeset, :request_id, "select exactly one report target")
end
end

View File

@ -0,0 +1,27 @@
defmodule WhoNeedHelp.Trust.Review do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "reviews" do
field :rating, :integer
field :comment, :string
field :revealed_at, :utc_datetime
belongs_to :assignment, WhoNeedHelp.Help.Assignment
belongs_to :reviewer, WhoNeedHelp.Accounts.User
belongs_to :reviewee, WhoNeedHelp.Accounts.User
timestamps(type: :utc_datetime)
end
def changeset(review, attrs) do
review
|> cast(attrs, [:rating, :comment, :assignment_id, :reviewer_id, :reviewee_id])
|> validate_required([:rating, :assignment_id, :reviewer_id, :reviewee_id])
|> validate_number(:rating, greater_than_or_equal_to: 1, less_than_or_equal_to: 5)
|> validate_length(:comment, max: 800)
|> unique_constraint([:assignment_id, :reviewer_id])
|> check_constraint(:reviewer_id, name: :review_cannot_target_self)
end
end

View File

@ -0,0 +1,63 @@
defmodule WhoNeedHelp.Workers.ExpireRequests do
use Oban.Worker, queue: :maintenance, unique: [period: 55]
import Ecto.Query
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking
alias WhoNeedHelp.Trust
alias WhoNeedHelp.Trust.RateLimiter
@impl Oban.Worker
def perform(_job) do
now = DateTime.utc_now(:second)
ids =
HelpRequest
|> where([r], r.status == :open and r.expires_at <= ^now)
|> select([request], request.id)
|> Repo.all()
expired =
Enum.count(ids, fn id ->
Repo.transact(fn ->
request =
HelpRequest
|> where(
[request],
request.id == ^id and request.status == :open and request.expires_at <= ^now
)
|> lock("FOR UPDATE")
|> Repo.one()
if request do
with {:ok, request} <-
request
|> Ecto.Changeset.change(status: :expired)
|> Repo.update(),
{:ok, _audit} <-
Trust.audit(nil, "request.expired", "request", request.id) do
{:ok, request}
end
else
{:error, :already_transitioned}
end
end)
|> case do
{:ok, _request} -> true
_other -> false
end
end)
{:ok, cleanup} = Tracking.cleanup_finished_sessions()
{pruned_buckets, _} = RateLimiter.prune_expired()
{:ok,
%{
expired: expired,
tracking_positions_deleted: cleanup.positions_deleted,
tracking_sessions_ended: cleanup.sessions_ended,
rate_limit_buckets_pruned: pruned_buckets
}}
end
end

115
lib/who_need_help_web.ex Normal file
View File

@ -0,0 +1,115 @@
defmodule WhoNeedHelpWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use WhoNeedHelpWeb, :controller
use WhoNeedHelpWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths,
do: ~w(assets fonts images favicon.ico manifest.webmanifest robots.txt sw.js)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: WhoNeedHelpWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: WhoNeedHelpWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import WhoNeedHelpWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias WhoNeedHelpWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: WhoNeedHelpWeb.Endpoint,
router: WhoNeedHelpWeb.Router,
statics: WhoNeedHelpWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -0,0 +1,505 @@
defmodule WhoNeedHelpWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: WhoNeedHelpWeb.Gettext
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash
id="welcome-back"
kind={:info}
phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
hidden
>
Welcome Back!
</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-bottom toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as radio, are best
written directly in your templates.
## Examples
```heex
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
```
## Select type
When using `type="select"`, you must pass the `options` and optionally
a `value` to mark which option should be preselected.
```heex
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
```
For more information on what kind of data can be passed to `options` see
[`options_for_select`](https://phoenix-html.hexdocs.pm/Phoenix.HTML.Form.html#options_for_select/2).
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(WhoNeedHelpWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(WhoNeedHelpWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View File

@ -0,0 +1,193 @@
defmodule WhoNeedHelpWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use WhoNeedHelpWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar sticky top-0 z-30 border-b border-base-300 bg-base-100/90 px-4 backdrop-blur sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex w-fit items-center gap-3">
<span class="grid size-9 place-items-center rounded-xl bg-success text-xl text-success-content">+</span>
<span class="font-black tracking-tight">Who Need Help</span>
</a>
</div>
<div class="flex-none">
<ul class="flex items-center gap-1 px-1">
<li>
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">
{gettext("Requests")}
</.link>
</li>
<li>
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
{gettext("Categories")}
</.link>
</li>
<li>
<.link navigate={~p"/leaderboard"} class="btn btn-ghost btn-sm">
{gettext("Helpers")}
</.link>
</li>
<li>
<.theme_toggle />
</li>
<%= if @current_scope do %>
<li :if={@current_scope.user.role in [:moderator, :admin]}>
<.link navigate={~p"/moderation"} class="btn btn-warning btn-sm">
{gettext("Moderation")}
</.link>
</li>
<li>
<.link navigate={~p"/profile"} class="btn btn-ghost btn-sm">
{gettext("Profile")}
</.link>
</li>
<li>
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm">
{gettext("Ask for help")}
</.link>
</li>
<% else %>
<li>
<.link navigate={~p"/users/log-in"} class="btn btn-primary btn-sm">
{gettext("Log in")}
</.link>
</li>
<% end %>
</ul>
</div>
</header>
<main class="px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-7xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<footer class="border-t border-base-300 px-4 py-6 text-center text-xs text-base-content/55">
<.link href={~p"/safety"} class="link">Safety rules</.link>
<span class="mx-2">·</span>
<.link href={~p"/feedback"} class="link">Build Week feedback</.link>
</footer>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error")
|> JS.remove_attribute("hidden", to: ".phx-client-error #client-error")
}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error")
|> JS.remove_attribute("hidden", to: ".phx-server-error #server-error")
}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 [[data-theme-source=system]_&]:!left-0 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html
lang={Gettext.get_locale(WhoNeedHelpWeb.Gettext)}
data-map-tile-url={Application.fetch_env!(:who_need_help, :map_tile_url)}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<meta name="theme-color" content="#16a34a" />
<.live_title default="Who Need Help" suffix=" · Who Need Help" phx-no-format>{assigns[:page_title]}</.live_title>
<link rel="manifest" href={~p"/manifest.webmanifest"} />
<link rel="icon" type="image/svg+xml" href={~p"/images/logo.svg"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.setAttribute("data-theme", systemTheme());
document.documentElement.setAttribute("data-theme-source", "system");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
document.documentElement.setAttribute("data-theme-source", "user");
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
if (document.documentElement.getAttribute("data-theme-source") === "system") {
document.documentElement.setAttribute("data-theme", systemTheme());
}
});
})();
</script>
</head>
<body>
<ul class="menu menu-horizontal relative z-40 flex w-full items-center justify-end gap-2 bg-base-100 px-4 text-xs sm:px-6 lg:px-8">
<%= if @current_scope do %>
<li>
<span class="flex flex-col items-end leading-tight">
<span>{@current_scope.user.display_name || @current_scope.user.email}</span>
<span class="text-[0.65rem] text-base-content/50">{@current_scope.user.email}</span>
</span>
</li>
<li>
<.link href={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>{gettext("Register")}</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>{gettext("Log in")}</.link>
</li>
<% end %>
<li class="flex flex-row gap-0">
<a href="?locale=en" aria-label="English">EN</a>
<a href="?locale=uk" aria-label="Українська">UK</a>
<a href="?locale=ru" aria-label="Русский">RU</a>
</li>
</ul>
{@inner_content}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More