diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index c01bce1..4661c98 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -11,6 +11,5 @@ # backend path and the same warning class. [ {"lib/who_need_help/help.ex", :call_without_opaque}, - {"lib/who_need_help/tracking.ex", :call_without_opaque}, {"lib/who_need_help_web/gettext.ex", :call_without_opaque} ] diff --git a/.env.e2e.example b/.env.e2e.example index b5de97b..e42a29e 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -3,6 +3,7 @@ # reuses the staging Compose project or database volume. HTTP_PORT=0 MAILPIT_PORT=0 +DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_PROJECT_CONSTRAINT=GENERATED_UNIQUE_E2E_PROJECT @@ -19,7 +20,11 @@ POSTGRES_DB=who_need_help_e2e POSTGRES_USER=postgres POSTGRES_PASSWORD=GENERATE_INDEPENDENT_E2E_DATABASE_PASSWORD DATABASE_URL=ecto://postgres:GENERATE_URL_SAFE_PASSWORD@db/who_need_help_e2e -POOL_SIZE=10 +WEB_POOL_SIZE=4 +WORKER_POOL_SIZE=2 +MIGRATE_POOL_SIZE=2 +WEB_REPLICAS=2 +WORKER_REPLICAS=2 ERLANG_PORT_LIMIT=65536 SECRET_KEY_BASE=GENERATE_INDEPENDENT_E2E_SECRET_KEY_BASE diff --git a/.env.example b/.env.example index d7f4c7f..ee2811b 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ # required values. Replace every credential before any public deployment. HTTP_PORT=4010 MAILPIT_PORT=8027 +MAILPIT_BIND_ADDRESS=127.0.0.1 +DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID # Comma-separated proxy IP/CIDR values whose X-Forwarded-* headers Traefik # accepts. Keep loopback locally; set the exact VPN proxy address for staging. TRAEFIK_TRUSTED_IPS=127.0.0.1/32 @@ -10,7 +12,7 @@ TRAEFIK_RETRY_ATTEMPTS=3 # own project constraint, router/service name, Docker network, and Host rule. TRAEFIK_PROJECT_CONSTRAINT=who_need_help TRAEFIK_APP_NAME=who-need-help -TRAEFIK_DOCKER_NETWORK=who_need_help_internal +TRAEFIK_DOCKER_NETWORK=who_need_help_ingress TRAEFIK_ROUTER_RULE='PathPrefix(`/`)' PHX_HOST=localhost PHX_SCHEME=http @@ -62,7 +64,11 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help -POOL_SIZE=10 +WEB_POOL_SIZE=4 +WORKER_POOL_SIZE=2 +MIGRATE_POOL_SIZE=2 +WEB_REPLICAS=2 +WORKER_REPLICAS=2 # Maximum simultaneously existing Erlang ports (files, sockets and drivers). # Keeping this explicit prevents a host's very large nofile ulimit from making # every BEAM instance preallocate a multi-gigabyte port table. diff --git a/.env.load.example b/.env.load.example index 1dd5f37..d402f29 100644 --- a/.env.load.example +++ b/.env.load.example @@ -6,6 +6,7 @@ LOAD_PROJECT=who_need_help_load LOAD_HOST=load.local HTTP_PORT=4110 MAILPIT_PORT=8127 +DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID PHX_HOST=load.local PHX_SCHEME=https PHX_URL_PORT=443 @@ -13,14 +14,18 @@ TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load TRAEFIK_APP_NAME=who-need-help-load -TRAEFIK_DOCKER_NETWORK=who_need_help_load_internal +TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress TRAEFIK_ROUTER_RULE='Host(`load.local`)' POSTGRES_DB=who_need_help_load POSTGRES_USER=wnh_load POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD DATABASE_URL=GENERATE_DATABASE_URL -POOL_SIZE=10 +WEB_POOL_SIZE=4 +WORKER_POOL_SIZE=2 +MIGRATE_POOL_SIZE=2 +WEB_REPLICAS=2 +WORKER_REPLICAS=2 ERLANG_PORT_LIMIT=65536 SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE HANDOVER_SECRET=GENERATE_HANDOVER_SECRET diff --git a/Dockerfile.postgis b/Dockerfile.postgis new file mode 100644 index 0000000..0109a66 --- /dev/null +++ b/Dockerfile.postgis @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1.20.0 + +FROM postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6 + +# PostgreSQL 18's image-owned volume path is already writable by this account. +# Starting non-root removes the entrypoint's need for the bundled gosu binary. +RUN rm /usr/local/bin/gosu + +USER postgres diff --git a/Dockerfile.socket-proxy b/Dockerfile.socket-proxy new file mode 100644 index 0000000..30088c9 --- /dev/null +++ b/Dockerfile.socket-proxy @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1.20.0 +FROM tecnativa/docker-socket-proxy:v0.4.2@sha256:1f3a6f303320723d199d2316a3e82b2e2685d86c275d5e3deeaf182573b47476 + +USER root + +# The upstream release image is immutable but its Alpine packages predate +# currently available security fixes. Keep the reviewed proxy implementation +# and apply the repository's current fixes; scripts/quality.sh scans the result. +RUN apk upgrade --no-cache + +USER haproxy diff --git a/compose.cpu-replay.yaml b/compose.cpu-replay.yaml new file mode 100644 index 0000000..6c60c2c --- /dev/null +++ b/compose.cpu-replay.yaml @@ -0,0 +1,14 @@ +# Optional measurement-only override. It constrains CPU visibility for each +# long-running BEAM container so scheduler-count and memory behavior can be +# reproduced on a many-core development host. Values are experiment inputs, +# not production recommendations or minimum requirements. +services: + web: + cpus: ${CPU_REPLAY_WEB_CPUS:?Set CPU_REPLAY_WEB_CPUS for this experiment} + environment: + ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}:${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}" + + worker: + cpus: ${CPU_REPLAY_WORKER_CPUS:?Set CPU_REPLAY_WORKER_CPUS for this experiment} + environment: + ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}:${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}" diff --git a/compose.load.yaml b/compose.load.yaml index 62400e8..1381d82 100644 --- a/compose.load.yaml +++ b/compose.load.yaml @@ -15,7 +15,7 @@ services: edge: aliases: - ${LOAD_HOST} - internal: {} + ingress: {} migrate: image: who-need-help:load diff --git a/compose.quality.yaml b/compose.quality.yaml index 7ce7a73..0e8120c 100644 --- a/compose.quality.yaml +++ b/compose.quality.yaml @@ -1,6 +1,6 @@ services: db: - image: postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6 + image: ${QUALITY_POSTGIS_IMAGE:?Set QUALITY_POSTGIS_IMAGE} environment: POSTGRES_DB: postgres POSTGRES_USER: ${QUALITY_POSTGRES_USER:?Set QUALITY_POSTGRES_USER} diff --git a/compose.yaml b/compose.yaml index 1ab0648..f3c6627 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,7 +16,6 @@ x-app-environment: &app-environment PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env} PORT: "4000" - POOL_SIZE: ${POOL_SIZE:?Set POOL_SIZE in .env after measuring the target profile} SMTP_RELAY: ${SMTP_RELAY:?Set SMTP_RELAY in .env} SMTP_PORT: ${SMTP_PORT:?Set SMTP_PORT in .env} SMTP_USERNAME: ${SMTP_USERNAME:-} @@ -45,11 +44,40 @@ x-app-environment: &app-environment PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-} services: + docker-api-proxy: + image: who-need-help:socket-proxy-local + build: + context: . + dockerfile: Dockerfile.socket-proxy + environment: + CONTAINERS: "1" + EVENTS: "1" + INFO: "1" + NETWORKS: "1" + PING: "1" + POST: "0" + VERSION: "1" + LOG_LEVEL: warning + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + group_add: + - ${DOCKER_SOCKET_GID:?Set DOCKER_SOCKET_GID to the numeric group of /var/run/docker.sock} + networks: [docker-api] + read_only: true + tmpfs: + - /run:uid=99,gid=99,mode=0755 + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + restart: unless-stopped + proxy: image: traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5 command: - --api.dashboard=false - --providers.docker=true + - --providers.docker.endpoint=tcp://docker-api-proxy:2375 - --providers.docker.exposedbydefault=false - --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`) - --entrypoints.web.address=:80 @@ -57,13 +85,22 @@ services: - --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32} ports: - "${HTTP_PORT:-4010}:80" - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: [edge, internal] + depends_on: + - docker-api-proxy + networks: [docker-api, edge, ingress] + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true restart: unless-stopped db: - image: postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a63116392b95726a4c913766cf6 + image: who-need-help:postgis-local + build: + context: . + dockerfile: Dockerfile.postgis environment: POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} @@ -79,13 +116,22 @@ services: volumes: - postgres_data:/var/lib/postgresql networks: [internal] + security_opt: + - no-new-privileges:true restart: unless-stopped mailpit: image: axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6 + user: "65534:65534" ports: - - "${MAILPIT_PORT:-8027}:8025" + - "${MAILPIT_BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8027}:8025" networks: [edge, internal] + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true restart: unless-stopped migrate: @@ -95,11 +141,18 @@ services: environment: <<: *app-environment APP_ROLE: migrate + POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}} command: ["/app/bin/migrate"] depends_on: db: condition: service_healthy networks: [internal] + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true restart: "no" web: @@ -108,12 +161,13 @@ services: <<: *app-environment APP_ROLE: web PHX_SERVER: "true" + POOL_SIZE: ${WEB_POOL_SIZE:-${POOL_SIZE:?Set WEB_POOL_SIZE in .env}} depends_on: migrate: condition: service_completed_successfully labels: - traefik.enable=true - - traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_internal} + - traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help} @@ -126,8 +180,14 @@ services: timeout: 3s retries: 10 deploy: - replicas: 2 - networks: [internal] + replicas: ${WEB_REPLICAS:-2} + networks: [ingress, internal, egress] + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true restart: unless-stopped worker: @@ -135,19 +195,36 @@ services: environment: <<: *app-environment APP_ROLE: worker + POOL_SIZE: ${WORKER_POOL_SIZE:-${POOL_SIZE:?Set WORKER_POOL_SIZE in .env}} command: ["/app/bin/who_need_help", "start"] depends_on: migrate: condition: service_completed_successfully + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] + interval: 10s + timeout: 3s + retries: 10 deploy: - replicas: 2 - networks: [internal] + replicas: ${WORKER_REPLICAS:-2} + networks: [internal, egress] + read_only: true + tmpfs: + - /tmp + cap_drop: [ALL] + security_opt: + - no-new-privileges:true restart: unless-stopped networks: + docker-api: + internal: true edge: + ingress: + internal: true internal: internal: true + egress: volumes: postgres_data: diff --git a/config/runtime.exs b/config/runtime.exs index de75ed7..b58f412 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -179,16 +179,16 @@ config :who_need_help, ), push_delivery_options: Keyword.delete(push_configuration, :adapter) -if config_env() == :prod and app_role == :web do +if config_env() == :prod and app_role in [:web, :worker] do metrics_token = System.get_env("METRICS_TOKEN") || raise """ - environment variable METRICS_TOKEN is missing for the web role. + environment variable METRICS_TOKEN is missing for the web or worker role. Generate an independent random value and store it in the deployment secret. """ if metrics_token == "" do - raise "METRICS_TOKEN must not be empty for the web role." + raise "METRICS_TOKEN must not be empty for the web or worker role." end config :who_need_help, :metrics_token, metrics_token diff --git a/deploy/helm/who-need-help/templates/deployments.yaml b/deploy/helm/who-need-help/templates/deployments.yaml index d147fca..33b4ba3 100644 --- a/deploy/helm/who-need-help/templates/deployments.yaml +++ b/deploy/helm/who-need-help/templates/deployments.yaml @@ -80,7 +80,7 @@ spec: - name: PORT value: {{ $root.Values.app.port | quote }} - name: POOL_SIZE - value: {{ $root.Values.app.poolSize | quote }} + value: {{ $settings.poolSize | quote }} - name: SMTP_RELAY value: {{ $root.Values.app.smtpRelay | quote }} - name: SMTP_PORT @@ -93,7 +93,6 @@ spec: 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 @@ -107,7 +106,6 @@ spec: path: /healthz/live port: http periodSeconds: 10 - {{- end }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deploy/helm/who-need-help/templates/migrate-job.yaml b/deploy/helm/who-need-help/templates/migrate-job.yaml index 9a5493c..80c4db3 100644 --- a/deploy/helm/who-need-help/templates/migrate-job.yaml +++ b/deploy/helm/who-need-help/templates/migrate-job.yaml @@ -35,6 +35,8 @@ spec: value: migrate - name: ERL_ZFLAGS value: {{ printf "+Q %d" (int .Values.app.erlangPortLimit) | quote }} + - name: POOL_SIZE + value: {{ .Values.app.migratePoolSize | quote }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true diff --git a/deploy/helm/who-need-help/templates/services.yaml b/deploy/helm/who-need-help/templates/services.yaml index 856c1d8..457e0b7 100644 --- a/deploy/helm/who-need-help/templates/services.yaml +++ b/deploy/helm/who-need-help/templates/services.yaml @@ -19,6 +19,23 @@ spec: --- apiVersion: v1 kind: Service +metadata: + name: {{ include "who-need-help.fullname" . }}-worker-metrics + labels: + {{- include "who-need-help.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + type: ClusterIP + selector: + {{- include "who-need-help.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: worker + ports: + - name: http + port: 4000 + targetPort: http +--- +apiVersion: v1 +kind: Service metadata: name: {{ include "who-need-help.fullname" . }}-headless labels: diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index 03f6e98..4b91478 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -5,9 +5,11 @@ image: web: replicas: 2 + poolSize: "4" worker: replicas: 2 + poolSize: "2" service: type: ClusterIP @@ -19,7 +21,7 @@ app: scheme: https urlPort: "443" port: "4000" - poolSize: "10" + migratePoolSize: "2" # OTP otherwise derives this from the container nofile ulimit. Some # Kubernetes runtimes expose a value so large that each BEAM instance # preallocates a multi-gigabyte port table. diff --git a/docs/performance.md b/docs/performance.md index 2562606..2e9c2fa 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -21,6 +21,23 @@ before any rollout. Compose and Helm now set that value explicitly, and the startup and rolling-verification scripts check the effective value in every running web and worker VM. +On a host with more CPUs than the intended server, the optional +`compose.cpu-replay.yaml` override can constrain the CPUs visible to each +long-running BEAM container: + +```sh +CPU_REPLAY_WEB_CPUS=1 CPU_REPLAY_WORKER_CPUS=1 \ +CPU_REPLAY_WEB_SCHEDULERS=1 CPU_REPLAY_WORKER_SCHEDULERS=1 \ + docker compose -f compose.yaml -f compose.cpu-replay.yaml up -d --wait +``` + +All four values are explicit experiment inputs. The override does not set a memory +limit or represent a whole-server CPU budget. The scheduler inputs make total +and online BEAM scheduler counts match the intended replay instead of leaving +offline scheduler threads sized from the development host. None of these +inputs is a production sizing recommendation. Running ordinary +`docker compose up` without the override removes the replay constraints. + After the Helm rollout, the unchanged kind control-plane container measured 1.796 GiB, a reduction of 7.952 GiB (81.6%). Its four application pod cgroups measured 188.5–215.6 MiB in the first post-change sample. A subsequent @@ -295,6 +312,35 @@ Ignored before/after evidence: - `output/performance/pool-sql-final-20260720/` - `output/performance/pool-sql-chat-fixed-canonical-20260720/` +## Observed role-specific connection pools + +On 2026-07-20 the same 3-web/2-worker, 40 public HTTP VU, 40 Phoenix +heartbeat VU, and 8 authenticated mutual-aid VU profile was repeated with +four Repo connections per web replica and two per worker replica. The migrate +role also started and completed with a two-connection pool. These are measured +development defaults, not production minimums. + +The 16 configured long-running Repo slots produced 38,678 HTTP requests with +zero failures, 1,760 complete authenticated page/chat/tracking chains, and 240 +Phoenix heartbeat sessions. The application recorded 131,409 web-side queries +with 34.82 microseconds average pool queue time, compared with 31.15 +microseconds in the earlier 10-connections-per-replica run. PostgreSQL observed +21 peak client backends, 6 peak active backends, 76 minimum non-reserved +connection headroom, no idle transaction, no lock wait, no rollback, no +deadlock, no conflict, and no temporary file. + +The run-scoped application log had no match for DBConnection/Postgrex +connection errors, pool timeouts, exceptions, deadlocks, `FATAL`, or `PANIC`. +All 1,760 messages, tracking sessions, and tracking samples were recorded; +explicit stop left zero current positions, and fixture cleanup restored the +tracked table counts exactly. The result supports using web=4, worker=2, and +migrate=2 as the repository's current measured starting profile. It does not +establish saturation, an SLO, or a production capacity limit. + +Ignored evidence: + +- `output/performance/role-pools-4-2-20260720/` + ## Observed 10-minute authenticated soak Observed on 2026-07-19 with the same 3-web/2-worker topology and the same diff --git a/lib/who_need_help/accounts.ex b/lib/who_need_help/accounts.ex index 2f331a8..c5e32a8 100644 --- a/lib/who_need_help/accounts.ex +++ b/lib/who_need_help/accounts.ex @@ -11,6 +11,43 @@ defmodule WhoNeedHelp.Accounts do ## Database getters + @public_user_fields [:id, :display_name, :bio, :tip_url, :inserted_at] + @public_social_identity_fields [ + :id, + :provider, + :profile_url, + :handle, + :verified_at, + :user_id, + :inserted_at + ] + + @doc """ + Returns the deliberately small user projection used by public and + participant-facing views. + + Authentication, account settings, and moderation queries must continue to + load the full schema explicitly. In particular, this projection never loads + email, password hashes, roles, or moderation notes. + """ + def public_user_query(options \\ []) do + query = + from user in User, + select: struct(user, ^@public_user_fields) + + if Keyword.get(options, :social_identities, false) do + public_social_identity = public_social_identity_query() + preload(query, social_identities: ^public_social_identity) + else + query + end + end + + def public_social_identity_query do + from identity in SocialIdentity, + select: struct(identity, ^@public_social_identity_fields) + end + @doc """ Gets a user by email. @@ -72,33 +109,31 @@ defmodule WhoNeedHelp.Accounts do 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 + Repo.exists?( + from user in User, + where: + user.id == ^user_id and user.moderation_status == :active and + not is_nil(user.confirmed_at) and not is_nil(user.accepted_terms_at) + ) 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{id: id, role: role}) + when role in [:moderator, :admin], + do: + Repo.exists?( + from user in User, where: user.id == ^id and user.role in [:moderator, :admin] + ) 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{id: id, role: :admin}), + do: Repo.exists?(from user in User, where: user.id == ^id and user.role == :admin) def admin_authorized?(_user), do: false diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 0206785..e3eb830 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -5,6 +5,7 @@ defmodule WhoNeedHelp.Activities do import Ecto.Query + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Activities.{Activity, Message, Participant} alias WhoNeedHelp.Catalog @@ -36,6 +37,7 @@ defmodule WhoNeedHelp.Activities do now = DateTime.utc_now(:second) limit = Pagination.limit(options) cursor = Pagination.cursor(options) + public_creator = Accounts.public_user_query(social_identities: true) Activity |> where( @@ -59,9 +61,9 @@ defmodule WhoNeedHelp.Activities do |> after_open_activity(cursor) |> order_by([activity], asc: activity.starts_at, asc: activity.id) |> limit(^(limit + 1)) - |> preload(category: :parent, creator: :social_identities, participants: :user) + |> with_approved_participant_count() + |> preload([activity], category: :parent, creator: ^public_creator) |> Repo.all() - |> Enum.map(&public_activity/1) |> Pagination.page(limit, &{&1.starts_at, &1.id}) end @@ -82,7 +84,8 @@ defmodule WhoNeedHelp.Activities do |> before_my_activity(cursor) |> order_by([activity], desc: activity.starts_at, desc: activity.id) |> limit(^(limit + 1)) - |> preload(category: :parent, participants: :user) + |> with_approved_participant_count() + |> preload([activity], category: :parent) |> Repo.all() |> Pagination.page(limit, &{&1.starts_at, &1.id}) end @@ -121,13 +124,11 @@ defmodule WhoNeedHelp.Activities do end def get_activity(%Scope{user: user}, id) do - case Repo.get(Activity, id) do + case get_loaded_activity(id) do nil -> {:error, :not_found} activity -> - activity = load_activity(activity.id) - cond do activity.creator_id == user.id -> {:ok, activity} @@ -154,6 +155,7 @@ defmodule WhoNeedHelp.Activities do if activity.creator_id == user.id or approved_participant?(activity, user.id) do limit = Pagination.limit(options, 50) cursor = Pagination.cursor(options) + public_user = Accounts.public_user_query() page = Message @@ -161,7 +163,7 @@ defmodule WhoNeedHelp.Activities do |> before_message(cursor) |> order_by([message], desc: message.inserted_at, desc: message.id) |> limit(^(limit + 1)) - |> preload(:sender) + |> preload([message], sender: ^public_user) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) @@ -356,7 +358,7 @@ defmodule WhoNeedHelp.Activities do |> Repo.insert() |> case do {:ok, message} -> - message = Repo.preload(message, :sender) + message = Repo.preload(message, sender: Accounts.public_user_query()) Phoenix.PubSub.broadcast( WhoNeedHelp.PubSub, @@ -559,18 +561,34 @@ defmodule WhoNeedHelp.Activities do end defp load_activity(id) do - recent_messages = - from message in Message, - order_by: [desc: message.inserted_at, desc: message.id], - limit: 50, - preload: :sender - Activity |> Repo.get!(id) - |> Repo.preload( + |> preload_activity() + end + + defp get_loaded_activity(id) do + Activity + |> Repo.get(id) + |> preload_activity() + end + + defp preload_activity(nil), do: nil + + defp preload_activity(activity) do + public_user = Accounts.public_user_query(social_identities: true) + public_sender = Accounts.public_user_query() + + recent_messages = + Message + |> order_by([message], desc: message.inserted_at, desc: message.id) + |> limit(50) + |> preload([message], sender: ^public_sender) + + Repo.preload( + activity, category: :parent, - creator: :social_identities, - participants: [user: :social_identities], + creator: public_user, + participants: [user: public_user], messages: recent_messages ) end @@ -587,8 +605,24 @@ defmodule WhoNeedHelp.Activities do } end - defp public_activity(activity) do - %{activity | participants: Enum.filter(activity.participants, &(&1.status == :approved))} + defp with_approved_participant_count(query) do + counts = + Participant + |> where([participant], participant.status == :approved) + |> group_by([participant], participant.activity_id) + |> select([participant], %{ + activity_id: participant.activity_id, + approved_count: count(participant.id) + }) + + query + |> join(:left, [activity], count in subquery(counts), + on: count.activity_id == activity.id, + as: :approved_counts + ) + |> select_merge([approved_counts: count], %{ + approved_participant_count: fragment("coalesce(?, 0)", count.approved_count) + }) end defp locked_activity(id) do diff --git a/lib/who_need_help/activities/activity.ex b/lib/who_need_help/activities/activity.ex index c570862..a64e3db 100644 --- a/lib/who_need_help/activities/activity.ex +++ b/lib/who_need_help/activities/activity.ex @@ -24,6 +24,7 @@ defmodule WhoNeedHelp.Activities.Activity do field :completed_at, :utc_datetime field :hidden_at, :utc_datetime field :hidden_reason, :string + field :approved_participant_count, :integer, virtual: true, default: 0 belongs_to :creator, WhoNeedHelp.Accounts.User belongs_to :category, WhoNeedHelp.Catalog.Category diff --git a/lib/who_need_help/application.ex b/lib/who_need_help/application.ex index 4e78aab..8db5bf5 100644 --- a/lib/who_need_help/application.ex +++ b/lib/who_need_help/application.ex @@ -26,9 +26,19 @@ defmodule WhoNeedHelp.Application do role_children = case Application.fetch_env!(:who_need_help, :app_role) do - :web -> [{Oban, oban_client_config}, WhoNeedHelpWeb.Presence, WhoNeedHelpWeb.Endpoint] - :worker -> [{Oban, oban_config}] - :migrate -> [{Oban, oban_client_config}] + :web -> + [ + {Oban, oban_client_config}, + WhoNeedHelpWeb.Presence, + WhoNeedHelp.TrackingPresenceCleanup, + WhoNeedHelpWeb.Endpoint + ] + + :worker -> + [{Oban, oban_config}, WhoNeedHelpWeb.WorkerMetricsPlug] + + :migrate -> + [{Oban, oban_client_config}] end children = common_children ++ role_children diff --git a/lib/who_need_help/catalog.ex b/lib/who_need_help/catalog.ex index efd4626..4e0c84e 100644 --- a/lib/who_need_help/catalog.ex +++ b/lib/who_need_help/catalog.ex @@ -40,13 +40,15 @@ defmodule WhoNeedHelp.Catalog do def paginate_proposals(options \\ []) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) + public_user = Accounts.public_user_query() CategoryProposal |> where([p], p.status == :open) - |> preload([:proposer, :parent, :votes]) |> before_proposal(cursor) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> limit(^(limit + 1)) + |> with_vote_count() + |> preload([proposal], proposer: ^public_user, parent: []) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) end @@ -163,7 +165,8 @@ defmodule WhoNeedHelp.Catalog do |> before_proposal(cursor) |> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id) |> limit(^(limit + 1)) - |> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes]) + |> with_vote_count() + |> preload([proposal], [:parent, :merged_into]) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) else @@ -833,6 +836,22 @@ defmodule WhoNeedHelp.Catalog do ] end + defp with_vote_count(query) do + counts = + CategoryVote + |> group_by([vote], vote.proposal_id) + |> select([vote], %{proposal_id: vote.proposal_id, vote_count: count(vote.id)}) + + query + |> join(:left, [proposal], count in subquery(counts), + on: count.proposal_id == proposal.id, + as: :vote_counts + ) + |> select_merge([vote_counts: count], %{ + vote_count: fragment("coalesce(?, 0)", count.vote_count) + }) + end + defp before_proposal(query, nil), do: query defp before_proposal(query, {inserted_at, id}) do diff --git a/lib/who_need_help/catalog/category_proposal.ex b/lib/who_need_help/catalog/category_proposal.ex index 318d9c7..abeaac1 100644 --- a/lib/who_need_help/catalog/category_proposal.ex +++ b/lib/who_need_help/catalog/category_proposal.ex @@ -16,6 +16,7 @@ defmodule WhoNeedHelp.Catalog.CategoryProposal do belongs_to :reviewed_by, WhoNeedHelp.Accounts.User field :reviewed_at, :utc_datetime field :moderation_note, :string + field :vote_count, :integer, virtual: true, default: 0 has_many :votes, WhoNeedHelp.Catalog.CategoryVote, foreign_key: :proposal_id timestamps(type: :utc_datetime) diff --git a/lib/who_need_help/catalog_moderation.ex b/lib/who_need_help/catalog_moderation.ex index 8a15ac0..f9dfb14 100644 --- a/lib/who_need_help/catalog_moderation.ex +++ b/lib/who_need_help/catalog_moderation.ex @@ -19,7 +19,7 @@ defmodule WhoNeedHelp.CatalogModeration do reason: proposal.reason, mode: proposal.mode, parent_slug: parent_slug(proposal.parent), - community_votes: length(proposal.votes) + community_votes: proposal.vote_count } end) diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index ed56737..97da558 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -57,8 +57,8 @@ defmodule WhoNeedHelp.Help do |> after_open_request(cursor) |> order_by([request], asc: request.expires_at, asc: request.id) |> limit(^(limit + 1)) - |> preload([:category, :requester, assignment: :helper]) |> Repo.all() + |> preload_request_relations() |> Pagination.page(limit, &{&1.expires_at, &1.id}) end @@ -77,8 +77,8 @@ defmodule WhoNeedHelp.Help do |> before_my_request(cursor) |> order_by([request], desc: request.inserted_at, desc: request.id) |> limit(^(limit + 1)) - |> preload([:category, :requester, assignment: :helper]) |> Repo.all() + |> preload_request_relations() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) end @@ -107,31 +107,39 @@ defmodule WhoNeedHelp.Help do def get_request!(id) do HelpRequest |> Repo.get!(id) - |> Repo.preload([ - :category, - requester: :social_identities, - assignment: [helper: :social_identities] - ]) + |> then(&preload_request_relations([&1], social_identities: true)) + |> hd() end def get_request(%Scope{user: user} = scope, id) do - case Repo.get(HelpRequest, id) do + request = + HelpRequest + |> Repo.get(id) + |> case do + nil -> + nil + + request -> + [request] + |> preload_request_relations(social_identities: true) + |> hd() + end + + case request 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} + Accounts.moderator_authorized?(user) -> + {:ok, request} + not is_nil(request.hidden_at) -> {:error, :not_found} @@ -146,7 +154,10 @@ defmodule WhoNeedHelp.Help do def get_assignment_for_participant(%Scope{} = scope, id) do with {:ok, id} <- Ecto.UUID.cast(id), - %Assignment{} = assignment <- Repo.get(Assignment, id), + %Assignment{} = assignment <- + Assignment + |> Repo.get(id) + |> Repo.preload(:request), true <- participant?(scope, assignment) do {:ok, assignment} else @@ -248,7 +259,12 @@ defmodule WhoNeedHelp.Help do {:ok, assignment} -> request = get_request!(assignment.request_id) broadcast({:request_updated, request}) - {:ok, Repo.preload(assignment, [:helper, :request])} + + {:ok, + Repo.preload(assignment, + helper: Accounts.public_user_query(), + request: [] + )} other -> other @@ -389,6 +405,55 @@ defmodule WhoNeedHelp.Help do user.id in [assignment.helper_id, request.requester_id] end + defp preload_request_relations(requests, options \\ []) do + requests = Repo.preload(requests, [:category, :assignment]) + + user_ids = + requests + |> Enum.flat_map(fn request -> + [request.requester_id, request.assignment && request.assignment.helper_id] + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + users = + Accounts.public_user_query() + |> where([user], user.id in ^user_ids) + |> Repo.all() + |> maybe_preload_social_identities(options) + |> Map.new(&{&1.id, &1}) + + Enum.map(requests, fn request -> + assignment = + case request.assignment do + %Assignment{} = assignment -> + %{assignment | helper: Map.fetch!(users, assignment.helper_id)} + + nil -> + nil + end + + request + |> Map.put(:requester, Map.fetch!(users, request.requester_id)) + |> Map.put(:assignment, assignment) + |> attach_assignment_request() + end) + end + + defp maybe_preload_social_identities(users, options) do + if Keyword.get(options, :social_identities, false) do + Repo.preload(users, social_identities: Accounts.public_social_identity_query()) + else + users + end + end + + defp attach_assignment_request(%HelpRequest{assignment: %Assignment{} = assignment} = request) do + %{request | assignment: %{assignment | request: request}} + end + + defp attach_assignment_request(%HelpRequest{} = request), do: request + 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 @@ -506,7 +571,13 @@ defmodule WhoNeedHelp.Help 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)} + + {:ok, + Repo.preload( + assignment, + [helper: Accounts.public_user_query(), request: []], + force: true + )} end defp after_transition(other), do: other diff --git a/lib/who_need_help/messaging.ex b/lib/who_need_help/messaging.ex index 2f25ef6..c878a7e 100644 --- a/lib/who_need_help/messaging.ex +++ b/lib/who_need_help/messaging.ex @@ -2,6 +2,7 @@ defmodule WhoNeedHelp.Messaging do @moduledoc "Durable match chat with PubSub fan-out after commit." import Ecto.Query + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Help alias WhoNeedHelp.Help.Assignment @@ -20,10 +21,13 @@ defmodule WhoNeedHelp.Messaging do end def paginate_messages(%Scope{} = scope, %Assignment{} = assignment, options \\ []) do + assignment = Repo.preload(assignment, :request) + if Trust.eligible?(scope) and Help.participant?(scope, assignment) and not blocked_assignment?(scope, assignment) do limit = Pagination.limit(options, 50) cursor = Pagination.cursor(options) + public_user = Accounts.public_user_query() page = Message @@ -31,7 +35,7 @@ defmodule WhoNeedHelp.Messaging do |> before_message(cursor) |> order_by([message], desc: message.inserted_at, desc: message.id) |> limit(^(limit + 1)) - |> preload(:sender) + |> preload([message], sender: ^public_user) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) @@ -42,10 +46,12 @@ defmodule WhoNeedHelp.Messaging do end def send_message(%Scope{user: user} = scope, %Assignment{} = assignment, attrs) do + assignment = Repo.preload(assignment, :request) + with {:ok, _limit} <- Trust.authorize_action(scope, :send_message), true <- Help.participant?(scope, assignment), false <- blocked_assignment?(scope, assignment) do - request = assignment |> Repo.preload(:request) |> Map.fetch!(:request) + request = Map.fetch!(assignment, :request) recipient_id = counterpart_id(user.id, assignment, request) result = @@ -66,7 +72,7 @@ defmodule WhoNeedHelp.Messaging do end) with {:ok, message} <- result do - message = Repo.preload(message, :sender) + message = Repo.preload(message, sender: Accounts.public_user_query()) Phoenix.PubSub.broadcast( WhoNeedHelp.PubSub, diff --git a/lib/who_need_help/tracking.ex b/lib/who_need_help/tracking.ex index 70f5e0c..5bc5427 100644 --- a/lib/who_need_help/tracking.ex +++ b/lib/who_need_help/tracking.ex @@ -9,7 +9,7 @@ defmodule WhoNeedHelp.Tracking do """ import Ecto.Query - alias Ecto.Multi + alias WhoNeedHelp.Accounts alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Help alias WhoNeedHelp.Help.Assignment @@ -21,15 +21,11 @@ defmodule WhoNeedHelp.Tracking do Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}") end - def active_session?(%Scope{user: user} = scope, %Assignment{} = assignment) do - Help.participant?(scope, assignment) && - Repo.exists?( - from session in TrackingSession, - where: - session.assignment_id == ^assignment.id and session.user_id == ^user.id and - session.active - ) - end + def active_session?(%Scope{user: user} = scope, %Assignment{} = assignment), + do: not is_nil(active_session(scope, assignment, user.id)) + + def active_session(%Scope{user: user} = scope, %Assignment{} = assignment), + do: active_session(scope, assignment, user.id) def start_session( %Scope{user: user} = scope, @@ -97,43 +93,31 @@ defmodule WhoNeedHelp.Tracking do 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 + stop_locked_session(assignment.id, user.id) else {:error, :forbidden} end end + def stop_browser_session(assignment_id, user_id, tracking_session_id) do + with {:ok, assignment_id} <- Ecto.UUID.cast(assignment_id), + {:ok, user_id} <- Ecto.UUID.cast(user_id), + {:ok, tracking_session_id} <- Ecto.UUID.cast(tracking_session_id), + %Assignment{} = assignment <- + Assignment + |> Repo.get(assignment_id) + |> Repo.preload(:request), + %Accounts.User{} = user <- Repo.get(Accounts.User, user_id) do + if Help.participant?(Scope.for_user(user), assignment) do + stop_locked_session(assignment.id, user.id, tracking_session_id) + else + {:error, :forbidden} + end + else + _missing_or_invalid -> {:ok, :already_stopped} + end + end + def list_current_positions(%Scope{} = scope, %Assignment{} = assignment) do if Trust.eligible?(scope) and Help.participant?(scope, assignment) do TrackingSession @@ -176,6 +160,67 @@ defmodule WhoNeedHelp.Tracking do end) end + defp active_session(scope, assignment, user_id) do + if Help.participant?(scope, assignment) do + Repo.get_by(TrackingSession, + assignment_id: assignment.id, + user_id: user_id, + active: true + ) + end + end + + defp stop_locked_session(assignment_id, user_id, tracking_session_id \\ nil) do + result = + Repo.transact(fn -> + query = + from session in TrackingSession, + where: + session.assignment_id == ^assignment_id and session.user_id == ^user_id and + session.active, + lock: "FOR UPDATE" + + query = + if tracking_session_id, + do: where(query, [session], session.id == ^tracking_session_id), + else: query + + case Repo.one(query) do + nil -> + {:ok, :already_stopped} + + session -> + Position + |> where([position], position.tracking_session_id == ^session.id) + |> Repo.delete_all() + + now = DateTime.utc_now(:second) + + case Repo.update(TrackingSession.changeset(session, %{active: false, ended_at: now})) do + {:ok, _session} -> {:ok, :stopped} + {:error, changeset} -> {:error, changeset} + end + end + end) + + case result do + {:ok, :stopped} -> + Phoenix.PubSub.broadcast( + WhoNeedHelp.PubSub, + "tracking:#{assignment_id}", + {:tracking_stopped, user_id} + ) + + {:ok, :stopped} + + {:ok, :already_stopped} -> + {:ok, :already_stopped} + + {:error, reason} -> + {:error, reason} + end + end + defp public_position(%Position{ position: %Geo.Point{coordinates: {lng, lat}}, accuracy_meters: accuracy, @@ -188,7 +233,6 @@ defmodule WhoNeedHelp.Tracking do previous = Position |> where([position], position.tracking_session_id == ^session.id) - |> lock("FOR UPDATE") |> Repo.one() now = DateTime.utc_now(:second) @@ -214,21 +258,9 @@ defmodule WhoNeedHelp.Tracking do }) |> 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) - - assignment = maybe_mark_proximity(assignment, session, position, now) - - evidence = %{ - helper_movement_observed_at: assignment.helper_movement_observed_at, - proximity_observed_at: assignment.proximity_observed_at - } + maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now) + maybe_mark_proximity(assignment, session, position, now) + evidence = assignment_evidence(assignment.id) {:ok, {position, evidence}} end @@ -237,17 +269,21 @@ defmodule WhoNeedHelp.Tracking do 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 + Assignment + |> where( + [candidate], + candidate.id == ^assignment.id and + is_nil(candidate.helper_movement_observed_at) + ) + |> Repo.update_all(set: [helper_movement_observed_at: now, updated_at: now]) end + + :ok end - defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed} = assignment, _, _, _) + defp maybe_mark_proximity(%Assignment{proximity_observed_at: observed}, _, _, _) when not is_nil(observed), - do: assignment + do: :ok defp maybe_mark_proximity(assignment, session, position, now) do counterpart_positions = @@ -262,12 +298,25 @@ defmodule WhoNeedHelp.Tracking do |> Repo.all() if Enum.any?(counterpart_positions, &accuracy_envelopes_overlap?(&1, position)) do - assignment - |> Assignment.changeset(%{proximity_observed_at: now}) - |> Repo.update!() - else - assignment + Assignment + |> where( + [candidate], + candidate.id == ^assignment.id and is_nil(candidate.proximity_observed_at) + ) + |> Repo.update_all(set: [proximity_observed_at: now, updated_at: now]) end + + :ok + end + + defp assignment_evidence(assignment_id) do + Assignment + |> where([assignment], assignment.id == ^assignment_id) + |> select([assignment], %{ + helper_movement_observed_at: assignment.helper_movement_observed_at, + proximity_observed_at: assignment.proximity_observed_at + }) + |> Repo.one!() end defp movement_evidence(nil, _current), do: 0.0 diff --git a/lib/who_need_help/tracking_presence_cleanup.ex b/lib/who_need_help/tracking_presence_cleanup.ex new file mode 100644 index 0000000..22f066c --- /dev/null +++ b/lib/who_need_help/tracking_presence_cleanup.ex @@ -0,0 +1,32 @@ +defmodule WhoNeedHelp.TrackingPresenceCleanup do + @moduledoc false + + use GenServer + + alias WhoNeedHelp.Tracking + alias WhoNeedHelpWeb.Presence + + def start_link(options), do: GenServer.start_link(__MODULE__, options, name: __MODULE__) + + def maybe_stop(key, assignment_id, user_id, tracking_session_id) do + GenServer.cast( + __MODULE__, + {:maybe_stop, key, assignment_id, user_id, tracking_session_id} + ) + end + + @impl GenServer + def init(_options), do: {:ok, %{}} + + @impl GenServer + def handle_cast( + {:maybe_stop, key, assignment_id, user_id, tracking_session_id}, + state + ) do + if Presence.get_by_key(Presence.tracking_topic(), key) == [] do + Tracking.stop_browser_session(assignment_id, user_id, tracking_session_id) + end + + {:noreply, state} + end +end diff --git a/lib/who_need_help/trust.ex b/lib/who_need_help/trust.ex index 2bed1e7..44ab858 100644 --- a/lib/who_need_help/trust.ex +++ b/lib/who_need_help/trust.ex @@ -25,21 +25,17 @@ defmodule WhoNeedHelp.Trust do } def authorize_action(%Scope{user: %User{} = user}, action) do - user = Repo.get(User, user.id) + if Accounts.eligible_user_id?(user.id) do + case RateLimiter.check(action, user.id) do + {:error, :rate_limited} = error -> + create_velocity_signal_once(user.id, action) + error - 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 + result -> + result + end + else + {:error, :account_not_eligible} end end @@ -107,26 +103,45 @@ defmodule WhoNeedHelp.Trust do def paginate_visible_reviews(user_id, options \\ []) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) + public_user = Accounts.public_user_query() Review |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) |> before_review(cursor) |> order_by([review], desc: review.inserted_at, desc: review.id) |> limit(^(limit + 1)) - |> preload(:reviewer) + |> preload([review], reviewer: ^public_user) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) end - def reputation(user_id) do + @empty_reputation %{ + completed: 0, + unique_people: 0, + verified_handovers: 0, + location_supported: 0, + rating: nil + } + + def reputation(user_id), do: Map.fetch!(reputations([user_id]), user_id) + + def reputations([]), do: %{} + + def reputations(user_ids) when is_list(user_ids) do + user_ids = Enum.uniq(user_ids) + helper_rows = Assignment |> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id ) - |> where([assignment], assignment.status == :completed and assignment.helper_id == ^user_id) + |> where( + [assignment], + assignment.status == :completed and assignment.helper_id in ^user_ids + ) |> select([assignment, request], %{ id: assignment.id, + user_id: assignment.helper_id, counterpart_id: request.requester_id, verified: not is_nil(assignment.handover_verified_at), location_supported: @@ -141,29 +156,64 @@ defmodule WhoNeedHelp.Trust do ) |> where( [assignment, request], - assignment.status == :completed and request.requester_id == ^user_id and - assignment.helper_id != ^user_id + assignment.status == :completed and request.requester_id in ^user_ids and + assignment.helper_id != request.requester_id ) - |> select([assignment], %{ + |> select([assignment, request], %{ id: assignment.id, + user_id: request.requester_id, counterpart_id: assignment.helper_id, verified: not is_nil(assignment.handover_verified_at), location_supported: not is_nil(assignment.proximity_observed_at) }) - aggregate = + aggregates = helper_rows |> union_all(^requester_rows) |> subquery() + |> group_by([row], row.user_id) |> select([row], %{ + user_id: row.user_id, completed: count(row.id), unique_people: count(row.counterpart_id, :distinct), verified_handovers: filter(count(row.id), row.verified), location_supported: filter(count(row.id), row.location_supported) }) - |> Repo.one!() - Map.put(aggregate, :rating, average_rating(user_id)) + ratings = + Review + |> where( + [review], + review.reviewee_id in ^user_ids and not is_nil(review.revealed_at) + ) + |> group_by([review], review.reviewee_id) + |> select([review], %{user_id: review.reviewee_id, rating: avg(review.rating)}) + + rows = + aggregates + |> subquery() + |> join(:left, [aggregate], rating in subquery(ratings), + on: rating.user_id == aggregate.user_id + ) + |> select([aggregate, rating], %{ + user_id: aggregate.user_id, + completed: aggregate.completed, + unique_people: aggregate.unique_people, + verified_handovers: aggregate.verified_handovers, + location_supported: aggregate.location_supported, + rating: rating.rating + }) + |> Repo.all() + + Enum.reduce(rows, Map.new(user_ids, &{&1, @empty_reputation}), fn row, reputations -> + Map.put( + reputations, + row.user_id, + row + |> Map.delete(:user_id) + |> Map.update!(:rating, &decimal_average/1) + ) + end) end def leaderboard do @@ -389,13 +439,14 @@ defmodule WhoNeedHelp.Trust do def paginate_blocks(%Scope{user: user}, options \\ []) do limit = Pagination.limit(options) cursor = Pagination.cursor(options) + public_user = Accounts.public_user_query() Block |> where([block], block.blocker_id == ^user.id) |> before_block(cursor) |> order_by([block], desc: block.inserted_at, desc: block.id) |> limit(^(limit + 1)) - |> preload(:blocked) + |> preload([block], blocked: ^public_user) |> Repo.all() |> Pagination.page(limit, &{&1.inserted_at, &1.id}) end @@ -685,14 +736,6 @@ defmodule WhoNeedHelp.Trust do |> Repo.insert() end - defp average_rating(user_id) do - Review - |> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at)) - |> select([review], avg(review.rating)) - |> Repo.one() - |> decimal_average() - end - defp users_by_id([]), do: %{} defp users_by_id(ids) do diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex index bbb1b6c..e1c9e71 100644 --- a/lib/who_need_help/workers/expire_requests.ex +++ b/lib/who_need_help/workers/expire_requests.ex @@ -12,25 +12,39 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do 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() + with {:ok, expired} <- expire_available(now, 0), + {:ok, cleanup} <- Tracking.cleanup_finished_sessions() do + {pruned_buckets, _} = RateLimiter.prune_expired() - 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() + {:ok, + %{ + expired: expired, + tracking_positions_deleted: cleanup.positions_deleted, + tracking_sessions_ended: cleanup.sessions_ended, + rate_limit_buckets_pruned: pruned_buckets + }} + end + end - if request do + # Claim a single row per short transaction. This keeps memory bounded and + # allows another worker to make progress without waiting for a large backlog + # lock. The recursion is tail-recursive and stops on the first empty claim. + defp expire_available(now, expired) do + result = + Repo.transact(fn -> + request = + HelpRequest + |> where([request], request.status == :open and request.expires_at <= ^now) + |> order_by([request], asc: request.expires_at, asc: request.id) + |> limit(1) + |> lock("FOR UPDATE SKIP LOCKED") + |> Repo.one() + + case request do + nil -> + {:ok, :empty} + + request -> with {:ok, request} <- request |> Ecto.Changeset.change(status: :expired) @@ -39,25 +53,13 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do 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 - }} + case result do + {:ok, :empty} -> {:ok, expired} + {:ok, %HelpRequest{}} -> expire_available(now, expired + 1) + {:error, reason} -> {:error, reason} + end end end diff --git a/lib/who_need_help_web/controllers/metrics_controller.ex b/lib/who_need_help_web/controllers/metrics_controller.ex index aea0839..6bf417b 100644 --- a/lib/who_need_help_web/controllers/metrics_controller.ex +++ b/lib/who_need_help_web/controllers/metrics_controller.ex @@ -1,6 +1,8 @@ defmodule WhoNeedHelpWeb.MetricsController do use WhoNeedHelpWeb, :controller + alias WhoNeedHelpWeb.MetricsAccess + @content_type "text/plain; version=0.0.4" # Sobelow's HTML-oriented SendResp check cannot infer this fixed Prometheus @@ -9,7 +11,7 @@ defmodule WhoNeedHelpWeb.MetricsController do def show(conn, _params) do token = Application.get_env(:who_need_help, :metrics_token) - if authorized?(get_req_header(conn, "authorization"), token) do + if MetricsAccess.authorized?(get_req_header(conn, "authorization"), token) do body = TelemetryMetricsPrometheus.Core.scrape(:prometheus_metrics) conn @@ -23,12 +25,4 @@ defmodule WhoNeedHelpWeb.MetricsController do |> send_resp(:unauthorized, "Unauthorized\n") end end - - defp authorized?(["Bearer " <> candidate], expected) - when is_binary(expected) and expected != "" and - byte_size(candidate) == byte_size(expected) do - Plug.Crypto.secure_compare(candidate, expected) - end - - defp authorized?(_authorization, _expected), do: false end diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index 3e53d9c..95db499 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -175,7 +175,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do )} - {Enum.count(activity.participants, &(&1.status == :approved))}/{activity.capacity} + {activity.approved_participant_count}/{activity.capacity}
{proposal.reason}
- {gettext("%{count} community votes", count: length(proposal.votes))} + {gettext("%{count} community votes", count: proposal.vote_count)}