feat: implement Who Need Help MVP
This commit is contained in:
commit
7f56484527
59
.dockerignore
Normal file
59
.dockerignore
Normal 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
19
.env.example
Normal 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
6
.formatter.exs
Normal 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
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
* text=auto
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.sh text eol=lf
|
||||||
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal 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
145
Dockerfile
Normal 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
178
README.md
Normal 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
6
android/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.gradle
|
||||||
|
app/build
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
dist-*
|
||||||
|
local.properties
|
||||||
109
android/Dockerfile
Normal file
109
android/Dockerfile
Normal 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
70
android/README.md
Normal 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.
|
||||||
115
android/app/build.gradle.kts
Normal file
115
android/app/build.gradle.kts
Normal 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
1
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# The app uses only Android framework APIs. Keep rules are intentionally empty.
|
||||||
|
|
@ -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>
|
||||||
27
android/app/src/main/AndroidManifest.xml
Normal file
27
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
13
android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
8
android/app/src/main/res/values/strings.xml
Normal file
8
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
11
android/app/src/main/res/values/styles.xml
Normal file
11
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
17
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
17
android/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||||
4
android/app/src/main/res/xml/network_security_config.xml
Normal file
4
android/app/src/main/res/xml/network_security_config.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -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
3
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.3.0" apply false
|
||||||
|
}
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.useAndroidX=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
10
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
android/gradlew
vendored
Executable 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
82
android/gradlew.bat
vendored
Normal 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%
|
||||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal 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
122
assets/css/app.css
Normal 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
91
assets/js/app.js
Normal 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
108
assets/js/hooks.js
Normal 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
250
assets/package-lock.json
generated
Normal 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
7
assets/package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "who-need-help-assets",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"maplibre-gl": "5.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
assets/tsconfig.json
Normal file
32
assets/tsconfig.json
Normal 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
43
assets/vendor/heroicons.js
vendored
Normal 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
138
assets/vendor/topbar.js
vendored
Normal 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
118
compose.yaml
Normal 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
101
config/config.exs
Normal 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
78
config/dev.exs
Normal 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
33
config/prod.exs
Normal 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
187
config/runtime.exs
Normal 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
45
config/test.exs
Normal 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
|
||||||
7
deploy/helm/who-need-help/Chart.yaml
Normal file
7
deploy/helm/who-need-help/Chart.yaml
Normal 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"
|
||||||
26
deploy/helm/who-need-help/templates/_helpers.tpl
Normal file
26
deploy/helm/who-need-help/templates/_helpers.tpl
Normal 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 }}
|
||||||
121
deploy/helm/who-need-help/templates/deployments.yaml
Normal file
121
deploy/helm/who-need-help/templates/deployments.yaml
Normal 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 }}
|
||||||
35
deploy/helm/who-need-help/templates/ingress.yaml
Normal file
35
deploy/helm/who-need-help/templates/ingress.yaml
Normal 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 }}
|
||||||
40
deploy/helm/who-need-help/templates/migrate-job.yaml
Normal file
40
deploy/helm/who-need-help/templates/migrate-job.yaml
Normal 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"]
|
||||||
25
deploy/helm/who-need-help/templates/pdb.yaml
Normal file
25
deploy/helm/who-need-help/templates/pdb.yaml
Normal 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
|
||||||
18
deploy/helm/who-need-help/templates/secret.yaml
Normal file
18
deploy/helm/who-need-help/templates/secret.yaml
Normal 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 }}
|
||||||
34
deploy/helm/who-need-help/templates/services.yaml
Normal file
34
deploy/helm/who-need-help/templates/services.yaml
Normal 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
|
||||||
18
deploy/helm/who-need-help/values-kind.yaml
Normal file
18
deploy/helm/who-need-help/values-kind.yaml
Normal 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
|
||||||
51
deploy/helm/who-need-help/values.yaml
Normal file
51
deploy/helm/who-need-help/values.yaml
Normal 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
12
deploy/kind/cluster.yaml
Normal 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
|
||||||
69
deploy/kind/dependencies.yaml
Normal file
69
deploy/kind/dependencies.yaml
Normal 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
171
docs/architecture.md
Normal 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/)
|
||||||
26
docs/category-moderation-output.schema.json
Normal file
26
docs/category-moderation-output.schema.json
Normal 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"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
docs/decisions/0001-postgresql-postgis-over-spacetimedb.md
Normal file
54
docs/decisions/0001-postgresql-postgis-over-spacetimedb.md
Normal 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
118
docs/product-spec.md
Normal 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
114
docs/trust-safety.md
Normal 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
89
docs/verification.md
Normal 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
9
lib/who_need_help.ex
Normal 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
|
||||||
428
lib/who_need_help/accounts.ex
Normal file
428
lib/who_need_help/accounts.ex
Normal 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
|
||||||
33
lib/who_need_help/accounts/scope.ex
Normal file
33
lib/who_need_help/accounts/scope.ex
Normal 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
|
||||||
30
lib/who_need_help/accounts/social_identity.ex
Normal file
30
lib/who_need_help/accounts/social_identity.ex
Normal 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
|
||||||
219
lib/who_need_help/accounts/user.ex
Normal file
219
lib/who_need_help/accounts/user.ex
Normal 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
|
||||||
84
lib/who_need_help/accounts/user_notifier.ex
Normal file
84
lib/who_need_help/accounts/user_notifier.ex
Normal 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
|
||||||
158
lib/who_need_help/accounts/user_token.ex
Normal file
158
lib/who_need_help/accounts/user_token.ex
Normal 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
|
||||||
42
lib/who_need_help/application.ex
Normal file
42
lib/who_need_help/application.ex
Normal 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
|
||||||
297
lib/who_need_help/catalog.ex
Normal file
297
lib/who_need_help/catalog.ex
Normal 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
|
||||||
120
lib/who_need_help/catalog/category.ex
Normal file
120
lib/who_need_help/catalog/category.ex
Normal 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
|
||||||
37
lib/who_need_help/catalog/category_proposal.ex
Normal file
37
lib/who_need_help/catalog/category_proposal.ex
Normal 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
|
||||||
20
lib/who_need_help/catalog/category_vote.ex
Normal file
20
lib/who_need_help/catalog/category_vote.ex
Normal 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
|
||||||
34
lib/who_need_help/catalog_moderation.ex
Normal file
34
lib/who_need_help/catalog_moderation.ex
Normal 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
528
lib/who_need_help/help.ex
Normal 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
|
||||||
49
lib/who_need_help/help/assignment.ex
Normal file
49
lib/who_need_help/help/assignment.ex
Normal 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
|
||||||
102
lib/who_need_help/help/help_request.ex
Normal file
102
lib/who_need_help/help/help_request.ex
Normal 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
|
||||||
3
lib/who_need_help/mailer.ex
Normal file
3
lib/who_need_help/mailer.ex
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule WhoNeedHelp.Mailer do
|
||||||
|
use Swoosh.Mailer, otp_app: :who_need_help
|
||||||
|
end
|
||||||
64
lib/who_need_help/messaging.ex
Normal file
64
lib/who_need_help/messaging.ex
Normal 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
|
||||||
22
lib/who_need_help/messaging/message.ex
Normal file
22
lib/who_need_help/messaging/message.ex
Normal 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
|
||||||
5
lib/who_need_help/postgrex_types.ex
Normal file
5
lib/who_need_help/postgrex_types.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Postgrex.Types.define(
|
||||||
|
WhoNeedHelp.PostgrexTypes,
|
||||||
|
[Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(),
|
||||||
|
json: Jason
|
||||||
|
)
|
||||||
89
lib/who_need_help/release.ex
Normal file
89
lib/who_need_help/release.ex
Normal 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
|
||||||
5
lib/who_need_help/repo.ex
Normal file
5
lib/who_need_help/repo.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
defmodule WhoNeedHelp.Repo do
|
||||||
|
use Ecto.Repo,
|
||||||
|
otp_app: :who_need_help,
|
||||||
|
adapter: Ecto.Adapters.Postgres
|
||||||
|
end
|
||||||
289
lib/who_need_help/tracking.ex
Normal file
289
lib/who_need_help/tracking.ex
Normal 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
|
||||||
37
lib/who_need_help/tracking/position.ex
Normal file
37
lib/who_need_help/tracking/position.ex
Normal 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
|
||||||
46
lib/who_need_help/tracking/tracking_session.ex
Normal file
46
lib/who_need_help/tracking/tracking_session.ex
Normal 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
695
lib/who_need_help/trust.ex
Normal 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
|
||||||
44
lib/who_need_help/trust/abuse_signal.ex
Normal file
44
lib/who_need_help/trust/abuse_signal.ex
Normal 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
|
||||||
22
lib/who_need_help/trust/audit_event.ex
Normal file
22
lib/who_need_help/trust/audit_event.ex
Normal 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
|
||||||
21
lib/who_need_help/trust/block.ex
Normal file
21
lib/who_need_help/trust/block.ex
Normal 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
|
||||||
25
lib/who_need_help/trust/rate_limit_bucket.ex
Normal file
25
lib/who_need_help/trust/rate_limit_bucket.ex
Normal 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
|
||||||
84
lib/who_need_help/trust/rate_limiter.ex
Normal file
84
lib/who_need_help/trust/rate_limiter.ex
Normal 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
|
||||||
57
lib/who_need_help/trust/report.ex
Normal file
57
lib/who_need_help/trust/report.ex
Normal 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
|
||||||
27
lib/who_need_help/trust/review.ex
Normal file
27
lib/who_need_help/trust/review.ex
Normal 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
|
||||||
63
lib/who_need_help/workers/expire_requests.ex
Normal file
63
lib/who_need_help/workers/expire_requests.ex
Normal 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
115
lib/who_need_help_web.ex
Normal 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
|
||||||
505
lib/who_need_help_web/components/core_components.ex
Normal file
505
lib/who_need_help_web/components/core_components.ex
Normal 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
|
||||||
193
lib/who_need_help_web/components/layouts.ex
Normal file
193
lib/who_need_help_web/components/layouts.ex
Normal 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
|
||||||
80
lib/who_need_help_web/components/layouts/root.html.heex
Normal file
80
lib/who_need_help_web/components/layouts/root.html.heex
Normal 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
Loading…
Reference in New Issue
Block a user