feat: add measured cursor pagination
This commit is contained in:
parent
0d4fd82702
commit
a4414f09e4
14
README.md
14
README.md
|
|
@ -156,6 +156,20 @@ production release image. Its generated database credentials are random and
|
||||||
exist only for that run. The exact database volume, networks, temporary source
|
exist only for that run. The exact database volume, networks, temporary source
|
||||||
snapshot, and one-run images are removed automatically.
|
snapshot, and one-run images are removed automatically.
|
||||||
|
|
||||||
|
The cursor-pagination database benchmark also creates a one-run Compose
|
||||||
|
project, random database credentials, and a separate PostgreSQL volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/db-scale-benchmark.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
It seeds 50,000 rows in each large benchmark table by default, captures
|
||||||
|
PostgreSQL 18 JSON `EXPLAIN (ANALYZE, BUFFERS)` plans before and after the
|
||||||
|
cursor-index migration, verifies two consecutive keyset pages for gaps and
|
||||||
|
duplicates, and removes its database project and volume. `DB_SCALE_ROWS`
|
||||||
|
changes the sample size; it is an experiment input, not a resource minimum.
|
||||||
|
Ignored evidence is written below `output/db-scale/`.
|
||||||
|
|
||||||
The browser E2E command creates a uniquely named, isolated Compose project with
|
The browser E2E command creates a uniquely named, isolated Compose project with
|
||||||
its own PostGIS volume, Mailpit instance, Traefik proxy, two web replicas, and
|
its own PostGIS volume, Mailpit instance, Traefik proxy, two web replicas, and
|
||||||
two worker replicas:
|
two worker replicas:
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,57 @@ Stop the isolated containers without deleting their database volume:
|
||||||
The stop script reads the actual `LOAD_PROJECT`, refuses the staging project
|
The stop script reads the actual `LOAD_PROJECT`, refuses the staging project
|
||||||
name, and does not delete the volume. Volume deletion is intentionally not
|
name, and does not delete the volume. Volume deletion is intentionally not
|
||||||
automated.
|
automated.
|
||||||
|
|
||||||
|
## Isolated database scale and cursor plans
|
||||||
|
|
||||||
|
Run the database-only before/after measurement with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/db-scale-benchmark.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds the test image and creates a uniquely named Compose project,
|
||||||
|
random one-run PostgreSQL credentials, and a separate PostGIS volume. It
|
||||||
|
migrates only through `20260719004249`, seeds the isolated baseline, records
|
||||||
|
machine-readable PostgreSQL
|
||||||
|
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` plans, applies the generated
|
||||||
|
`20260719013320_add_keyset_pagination_indexes` migration, and records the same
|
||||||
|
queries again. It also verifies that two 25-row keyset pages exactly equal the
|
||||||
|
first 50 ordered rows without duplicates or gaps. The exact project, network,
|
||||||
|
volume, and image are removed by its trap.
|
||||||
|
|
||||||
|
`DB_SCALE_ROWS` controls the configured sample size and defaults to 50,000.
|
||||||
|
That default is a reproducible experiment input, not a minimum database size,
|
||||||
|
capacity claim, latency target, or production traffic model. The structural
|
||||||
|
gate checks the canonical sample's chosen cursor indexes and query correctness;
|
||||||
|
it does not fail on an arbitrary millisecond threshold.
|
||||||
|
|
||||||
|
Observed locally on 2026-07-19 with PostgreSQL 18.4, the canonical run created
|
||||||
|
50,000 rows in each of users, requests, assignments, messages, reviews,
|
||||||
|
reports, abuse signals, proposals, activities, and activity messages; it
|
||||||
|
created 50,500 activity participants and 49,998 block rows. The isolated
|
||||||
|
database occupied 231,225,023 bytes after the cursor migration.
|
||||||
|
|
||||||
|
| Query | Baseline observed | Cursor migration observed | Selected cursor index |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| Urgent-help discovery | 8.437 ms | 0.149 ms | `help_requests_discovery_cursor_index` |
|
||||||
|
| Requester history | 11.287 ms | 0.038 ms | `help_requests_requester_cursor_index` |
|
||||||
|
| Activity discovery | 8.760 ms | 0.061 ms | `activities_discovery_cursor_index` |
|
||||||
|
| Visible reviews | 8.997 ms | 0.039 ms | `reviews_visible_cursor_index` |
|
||||||
|
| All reports | 9.293 ms | 0.026 ms | `reports_cursor_index` |
|
||||||
|
| All category proposals | 6.776 ms | 0.028 ms | `category_proposals_cursor_index` |
|
||||||
|
| Moderation users | 5.805 ms | 0.032 ms | `users_moderation_cursor_index` |
|
||||||
|
| Blocks | 5.388 ms | 0.044 ms | `blocks_blocker_cursor_index` |
|
||||||
|
|
||||||
|
These are measurements of one warm local run and must not be interpreted as an
|
||||||
|
SLO or portable speedup. The measurement also exposed redundant candidate
|
||||||
|
indexes and an `OR`-based Activity membership query. The final migration keeps
|
||||||
|
the indexes PostgreSQL selected, uses partial discovery indexes for open,
|
||||||
|
non-hidden records, and the application joins the existing participant
|
||||||
|
membership invariant directly. Personal reputation and the leaderboard now
|
||||||
|
aggregate in PostgreSQL instead of loading all completed assignments into the
|
||||||
|
BEAM; the leaderboard itself uses composite keyset pagination.
|
||||||
|
|
||||||
|
Ignored evidence for the recorded run:
|
||||||
|
|
||||||
|
- `output/db-scale/20260719020251-1612535/`
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,16 @@ results from product limits and unknown production properties.
|
||||||
|
|
||||||
## Reproducible checks
|
## Reproducible checks
|
||||||
|
|
||||||
- The isolated `./scripts/quality.sh` run completed on 2026-07-19 with 153
|
- The isolated Phoenix suite completed on 2026-07-19 with 161
|
||||||
tests and 0 failures after the full localization changes
|
tests and 0 failures after cursor pagination, database aggregation, and the
|
||||||
|
full localization changes
|
||||||
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
||||||
- `mix compile --force --warnings-as-errors` and
|
- `mix compile --force --warnings-as-errors` and
|
||||||
`mix format --check-formatted`: passed against the same final source.
|
`mix format --check-formatted`: passed against the same final source.
|
||||||
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
- `./scripts/quality.sh` passed ShellCheck 0.11.0, Hadolint 2.14.0 at warning
|
||||||
threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy
|
threshold, actionlint 1.7.12, all four Compose renders, Helm lint, Trivy
|
||||||
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
source/rendered-manifest scanning, xref, Credo high-priority checks, Sobelow
|
||||||
strict/private checks, Hex audit, 153 Phoenix tests, both npm audits, and the
|
strict/private checks, Hex audit, 161 Phoenix tests, both npm audits, and the
|
||||||
production-image vulnerability scan. The rendered Helm manifest and Debian
|
production-image vulnerability scan. The rendered Helm manifest and Debian
|
||||||
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
13.6 release image each reported zero HIGH/CRITICAL findings under the
|
||||||
configured gates.
|
configured gates.
|
||||||
|
|
@ -83,6 +84,14 @@ results from product limits and unknown production properties.
|
||||||
select/boolean values use locale maps with an English fallback; the
|
select/boolean values use locale maps with an English fallback; the
|
||||||
compatibility `description` column remains as a fallback for pre-existing
|
compatibility `description` column remains as a fallback for pre-existing
|
||||||
categories.
|
categories.
|
||||||
|
- The isolated database-scale harness migrated an empty PostgreSQL 18.4
|
||||||
|
database to the exact pre-index version, seeded the configured canonical
|
||||||
|
sample, captured JSON `EXPLAIN (ANALYZE, BUFFERS)` plans before and after the
|
||||||
|
generated cursor-index migration, and removed its project and volume. The
|
||||||
|
final 50,000-row-per-large-table run selected every asserted cursor index;
|
||||||
|
two consecutive request pages matched the first 50 ordered rows with no gap
|
||||||
|
or duplicate. Exact observations and their non-SLO limitations are recorded
|
||||||
|
in `docs/performance.md`.
|
||||||
- Browser verification used headed Chrome. The authenticated matched-request
|
- Browser verification used headed Chrome. The authenticated matched-request
|
||||||
page rendered its chat, MapLibre marker, and live-location controls with no
|
page rendered its chat, MapLibre marker, and live-location controls with no
|
||||||
console errors or warnings after the foreground-service rollout.
|
console errors or warnings after the foreground-service rollout.
|
||||||
|
|
@ -351,8 +360,8 @@ regression was repeated against the final source on 2026-07-18:
|
||||||
availability model.
|
availability model.
|
||||||
- Load-test representative data and traffic, then set measured pool, resource,
|
- Load-test representative data and traffic, then set measured pool, resource,
|
||||||
autoscaling, and action-limit policies.
|
autoscaling, and action-limit policies.
|
||||||
- Add pagination or bounded loading where real measurements show that request,
|
- Repeat representative authenticated write, chat, tracking, and reconnect
|
||||||
chat, moderation, or leaderboard result sets require it.
|
load scenarios before deriving production capacity or autoscaling policy.
|
||||||
- Publish jurisdiction-specific emergency contacts, privacy, retention,
|
- Publish jurisdiction-specific emergency contacts, privacy, retention,
|
||||||
prohibited-items, and voluntary-payment guidance after legal review.
|
prohibited-items, and voluntary-payment guidance after legal review.
|
||||||
- Create and configure a GitHub OAuth App, then exercise the real external
|
- Create and configure a GitHub OAuth App, then exercise the real external
|
||||||
|
|
|
||||||
706
lib/mix/tasks/wnh.db_scale_benchmark.ex
Normal file
706
lib/mix/tasks/wnh.db_scale_benchmark.ex
Normal file
|
|
@ -0,0 +1,706 @@
|
||||||
|
defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
|
@shortdoc "Measures keyset pagination plans on an isolated large data set"
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
Seeds and measures the isolated database used by `scripts/db-scale-benchmark.sh`.
|
||||||
|
|
||||||
|
The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
|
||||||
|
It intentionally does not enforce an execution-time threshold because timings
|
||||||
|
depend on the host. The `after` phase does enforce query correctness and that
|
||||||
|
each measured cursor query uses its purpose-built index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@default_rows 50_000
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(arguments) do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
|
{options, _rest, invalid} =
|
||||||
|
OptionParser.parse(arguments,
|
||||||
|
strict: [phase: :string, rows: :integer, output: :string]
|
||||||
|
)
|
||||||
|
|
||||||
|
if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")
|
||||||
|
|
||||||
|
phase = Keyword.get(options, :phase) || Mix.raise("--phase before|after is required")
|
||||||
|
rows = Keyword.get(options, :rows, @default_rows)
|
||||||
|
output = Keyword.get(options, :output) || Mix.raise("--output PATH is required")
|
||||||
|
|
||||||
|
unless phase in ["before", "after"], do: Mix.raise("--phase must be before or after")
|
||||||
|
unless rows >= 3, do: Mix.raise("--rows must be at least 3 for relational fixtures")
|
||||||
|
|
||||||
|
File.mkdir_p!(output)
|
||||||
|
|
||||||
|
if phase == "before" do
|
||||||
|
seed!(rows)
|
||||||
|
else
|
||||||
|
assert_seed_size!(rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
analyze!()
|
||||||
|
plans = collect_plans!(phase, output)
|
||||||
|
assert_keyset_pages!()
|
||||||
|
|
||||||
|
summary = %{
|
||||||
|
phase: phase,
|
||||||
|
configured_rows_per_large_table: rows,
|
||||||
|
captured_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(),
|
||||||
|
postgres_version: scalar!("SHOW server_version"),
|
||||||
|
table_counts: table_counts(),
|
||||||
|
database_bytes: scalar!("SELECT pg_database_size(current_database())"),
|
||||||
|
index_bytes: index_sizes(),
|
||||||
|
plans: plans
|
||||||
|
}
|
||||||
|
|
||||||
|
summary_path = Path.join(output, "#{phase}-summary.json")
|
||||||
|
File.write!(summary_path, Jason.encode_to_iodata!(summary, pretty: true))
|
||||||
|
|
||||||
|
if phase == "after" do
|
||||||
|
assert_expected_indexes_exist!()
|
||||||
|
|
||||||
|
if rows == @default_rows do
|
||||||
|
assert_expected_indexes_used!(plans)
|
||||||
|
end
|
||||||
|
|
||||||
|
write_comparison!(output, summary)
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.shell().info("database scale #{phase} phase written to #{summary_path}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed!(rows) do
|
||||||
|
Repo.transaction(
|
||||||
|
fn ->
|
||||||
|
Enum.each(seed_statements(rows), &query!/1)
|
||||||
|
end,
|
||||||
|
timeout: :infinity
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp seed_statements(rows) do
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, email, display_name, confirmed_at, accepted_terms_at, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('user-' || value)::uuid,
|
||||||
|
'db-scale-' || value || '@example.invalid',
|
||||||
|
'DB scale user ' || value,
|
||||||
|
date_trunc('second', now()),
|
||||||
|
date_trunc('second', now()),
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO categories (
|
||||||
|
id, slug, names, descriptions, mode, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
md5('db-scale-category')::uuid,
|
||||||
|
'db-scale-help',
|
||||||
|
'{"en":"DB scale help"}'::jsonb,
|
||||||
|
'{"en":"Synthetic isolated benchmark category"}'::jsonb,
|
||||||
|
'help',
|
||||||
|
date_trunc('second', now()),
|
||||||
|
date_trunc('second', now())
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO help_requests (
|
||||||
|
id, title, description, location_label, location, status, urgency,
|
||||||
|
location_visibility, expires_at, hidden_at, requester_id, category_id,
|
||||||
|
inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('request-' || value)::uuid,
|
||||||
|
'DB scale request ' || value,
|
||||||
|
'Synthetic isolated database scale request ' || value,
|
||||||
|
'Kyiv benchmark point',
|
||||||
|
ST_SetSRID(ST_MakePoint(30.5 + (value % 100) / 10000.0, 50.4), 4326),
|
||||||
|
CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
|
||||||
|
'now',
|
||||||
|
'approximate_public',
|
||||||
|
date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
|
||||||
|
CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
|
||||||
|
md5('user-1')::uuid,
|
||||||
|
md5('db-scale-category')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO help_assignments (
|
||||||
|
id, status, handover_code_hash, handover_verified_at,
|
||||||
|
proximity_observed_at, helper_movement_observed_at, accepted_at,
|
||||||
|
completed_at, request_id, helper_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('assignment-' || value)::uuid,
|
||||||
|
'completed',
|
||||||
|
decode(md5('handover-' || value), 'hex'),
|
||||||
|
CASE WHEN value % 2 = 0 THEN date_trunc('second', now()) END,
|
||||||
|
CASE WHEN value % 3 = 0 THEN date_trunc('second', now()) END,
|
||||||
|
CASE WHEN value % 4 = 0 THEN date_trunc('second', now()) END,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()),
|
||||||
|
md5('request-' || value)::uuid,
|
||||||
|
md5('user-2')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now())
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO messages (
|
||||||
|
id, body, assignment_id, sender_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('message-' || value)::uuid,
|
||||||
|
'Synthetic isolated message ' || value,
|
||||||
|
md5('assignment-1')::uuid,
|
||||||
|
md5('user-' || CASE WHEN value % 2 = 0 THEN 1 ELSE 2 END)::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO reviews (
|
||||||
|
id, rating, comment, revealed_at, assignment_id, reviewer_id, reviewee_id,
|
||||||
|
inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('review-' || value)::uuid,
|
||||||
|
value % 5 + 1,
|
||||||
|
'Synthetic isolated review ' || value,
|
||||||
|
date_trunc('second', now()),
|
||||||
|
md5('assignment-' || value)::uuid,
|
||||||
|
md5('user-1')::uuid,
|
||||||
|
md5('user-2')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO reports (
|
||||||
|
id, reason, details, status, reporter_id, request_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('report-' || value)::uuid,
|
||||||
|
'safety',
|
||||||
|
'Synthetic isolated report ' || value,
|
||||||
|
CASE
|
||||||
|
WHEN value % 4 = 0 THEN 'open'
|
||||||
|
WHEN value % 4 = 1 THEN 'reviewing'
|
||||||
|
WHEN value % 4 = 2 THEN 'resolved'
|
||||||
|
ELSE 'dismissed'
|
||||||
|
END,
|
||||||
|
md5('user-2')::uuid,
|
||||||
|
md5('request-' || value)::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO blocks (id, blocker_id, blocked_id, inserted_at)
|
||||||
|
SELECT
|
||||||
|
md5('block-' || value)::uuid,
|
||||||
|
md5('user-2')::uuid,
|
||||||
|
md5('user-' || value)::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(3, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO abuse_signals (
|
||||||
|
id, kind, status, metadata, subject_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('signal-' || value)::uuid,
|
||||||
|
'db_scale_' || value,
|
||||||
|
CASE
|
||||||
|
WHEN value % 3 = 0 THEN 'open'
|
||||||
|
WHEN value % 3 = 1 THEN 'reviewed'
|
||||||
|
ELSE 'dismissed'
|
||||||
|
END,
|
||||||
|
'{}'::jsonb,
|
||||||
|
md5('user-' || value)::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO category_proposals (
|
||||||
|
id, proposed_name, reason, status, proposer_id, mode, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('proposal-' || value)::uuid,
|
||||||
|
'DB scale proposal ' || value,
|
||||||
|
'Synthetic isolated category proposal ' || value,
|
||||||
|
CASE
|
||||||
|
WHEN value % 4 = 0 THEN 'open'
|
||||||
|
WHEN value % 4 = 1 THEN 'approved'
|
||||||
|
WHEN value % 4 = 2 THEN 'rejected'
|
||||||
|
ELSE 'merged'
|
||||||
|
END,
|
||||||
|
md5('user-1')::uuid,
|
||||||
|
'help',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO activities (
|
||||||
|
id, title, description, location_label, location, status, starts_at,
|
||||||
|
join_deadline, capacity, hidden_at, creator_id, category_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('activity-' || value)::uuid,
|
||||||
|
'DB scale activity ' || value,
|
||||||
|
'Synthetic isolated activity ' || value,
|
||||||
|
'Kyiv benchmark point',
|
||||||
|
ST_SetSRID(ST_MakePoint(30.5, 50.4 + (value % 100) / 10000.0), 4326),
|
||||||
|
CASE
|
||||||
|
WHEN value % 3 = 0 THEN 'open'
|
||||||
|
WHEN value % 3 = 1 THEN 'completed'
|
||||||
|
ELSE 'cancelled'
|
||||||
|
END,
|
||||||
|
date_trunc('second', now()) + (value + 1000) * interval '1 second',
|
||||||
|
date_trunc('second', now()) + (value + 700) * interval '1 second',
|
||||||
|
10,
|
||||||
|
CASE WHEN value % 30 = 0 THEN date_trunc('second', now()) END,
|
||||||
|
md5('user-1')::uuid,
|
||||||
|
md5('db-scale-category')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO activity_participants (
|
||||||
|
id, role, status, reviewed_at, activity_id, user_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('participant-' || value)::uuid,
|
||||||
|
'participant',
|
||||||
|
'approved',
|
||||||
|
date_trunc('second', now()),
|
||||||
|
md5('activity-' || value)::uuid,
|
||||||
|
md5('user-' || (value % 100 + 3))::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
md5('participant-user-2-' || value)::uuid,
|
||||||
|
'participant',
|
||||||
|
'approved',
|
||||||
|
date_trunc('second', now()),
|
||||||
|
md5('activity-' || value)::uuid,
|
||||||
|
md5('user-2')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(100, #{rows}, 100) AS value
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
INSERT INTO activity_messages (
|
||||||
|
id, body, activity_id, sender_id, inserted_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
md5('activity-message-' || value)::uuid,
|
||||||
|
'Synthetic isolated activity message ' || value,
|
||||||
|
md5('activity-1')::uuid,
|
||||||
|
md5('user-1')::uuid,
|
||||||
|
date_trunc('second', now()) - value * interval '1 second',
|
||||||
|
date_trunc('second', now()) - value * interval '1 second'
|
||||||
|
FROM generate_series(1, #{rows}) AS value
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp analyze! do
|
||||||
|
query!("""
|
||||||
|
ANALYZE users, help_requests, help_assignments, messages, reviews, reports,
|
||||||
|
blocks, abuse_signals, category_proposals, activities,
|
||||||
|
activity_participants, activity_messages
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_plans!(phase, output) do
|
||||||
|
plan_queries()
|
||||||
|
|> Map.new(fn {name, query} ->
|
||||||
|
plan = explain!(query)
|
||||||
|
|
||||||
|
File.write!(
|
||||||
|
Path.join(output, "#{phase}-#{name}.json"),
|
||||||
|
Jason.encode_to_iodata!(plan, pretty: true)
|
||||||
|
)
|
||||||
|
|
||||||
|
{name, summarize_plan(plan)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp plan_queries do
|
||||||
|
viewer = "md5('user-1')::uuid"
|
||||||
|
|
||||||
|
%{
|
||||||
|
"help_discovery" => """
|
||||||
|
SELECT id FROM help_requests
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
|
AND requester_id NOT IN (
|
||||||
|
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
|
||||||
|
)
|
||||||
|
AND requester_id NOT IN (
|
||||||
|
SELECT blocker_id FROM blocks WHERE blocked_id = #{viewer}
|
||||||
|
)
|
||||||
|
ORDER BY expires_at ASC, id ASC LIMIT 25
|
||||||
|
""",
|
||||||
|
"help_requester" => """
|
||||||
|
SELECT id FROM help_requests
|
||||||
|
WHERE requester_id = md5('user-1')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"activity_discovery" => """
|
||||||
|
SELECT id FROM activities
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL
|
||||||
|
AND starts_at > now() AND join_deadline > now()
|
||||||
|
ORDER BY starts_at ASC, id ASC LIMIT 25
|
||||||
|
""",
|
||||||
|
"activity_participant" => """
|
||||||
|
SELECT activities.id
|
||||||
|
FROM activities
|
||||||
|
INNER JOIN activity_participants
|
||||||
|
ON activity_participants.activity_id = activities.id
|
||||||
|
AND activity_participants.user_id = md5('user-2')::uuid
|
||||||
|
AND activity_participants.status IN ('requested', 'approved')
|
||||||
|
ORDER BY activities.starts_at DESC, activities.id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"messages" => """
|
||||||
|
SELECT id FROM messages
|
||||||
|
WHERE assignment_id = md5('assignment-1')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 51
|
||||||
|
""",
|
||||||
|
"activity_messages" => """
|
||||||
|
SELECT id FROM activity_messages
|
||||||
|
WHERE activity_id = md5('activity-1')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 51
|
||||||
|
""",
|
||||||
|
"visible_reviews" => """
|
||||||
|
SELECT id FROM reviews
|
||||||
|
WHERE reviewee_id = md5('user-2')::uuid AND revealed_at IS NOT NULL
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"reports_open" => """
|
||||||
|
SELECT id FROM reports
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"reports_all" => """
|
||||||
|
SELECT id FROM reports
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"blocks" => """
|
||||||
|
SELECT id FROM blocks
|
||||||
|
WHERE blocker_id = md5('user-2')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"abuse_signals" => """
|
||||||
|
SELECT id FROM abuse_signals
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"proposals_open" => """
|
||||||
|
SELECT id FROM category_proposals
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"proposals_all" => """
|
||||||
|
SELECT id FROM category_proposals
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"users" => """
|
||||||
|
SELECT id FROM users
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
"reputation_helper" => """
|
||||||
|
SELECT
|
||||||
|
count(help_assignments.id),
|
||||||
|
count(DISTINCT help_requests.requester_id),
|
||||||
|
count(help_assignments.id) FILTER (
|
||||||
|
WHERE help_assignments.handover_verified_at IS NOT NULL
|
||||||
|
),
|
||||||
|
count(help_assignments.id) FILTER (
|
||||||
|
WHERE help_assignments.proximity_observed_at IS NOT NULL
|
||||||
|
AND help_assignments.helper_movement_observed_at IS NOT NULL
|
||||||
|
)
|
||||||
|
FROM help_assignments
|
||||||
|
INNER JOIN help_requests
|
||||||
|
ON help_requests.id = help_assignments.request_id
|
||||||
|
WHERE help_assignments.status = 'completed'
|
||||||
|
AND help_assignments.helper_id = md5('user-2')::uuid
|
||||||
|
""",
|
||||||
|
"leaderboard_aggregate" => """
|
||||||
|
SELECT
|
||||||
|
help_assignments.helper_id,
|
||||||
|
count(help_assignments.id),
|
||||||
|
count(DISTINCT help_requests.requester_id),
|
||||||
|
count(DISTINCT help_requests.requester_id) FILTER (
|
||||||
|
WHERE help_assignments.proximity_observed_at IS NOT NULL
|
||||||
|
AND help_assignments.helper_movement_observed_at IS NOT NULL
|
||||||
|
),
|
||||||
|
count(DISTINCT help_requests.requester_id) FILTER (
|
||||||
|
WHERE help_assignments.handover_verified_at IS NOT NULL
|
||||||
|
)
|
||||||
|
FROM help_assignments
|
||||||
|
INNER JOIN help_requests
|
||||||
|
ON help_requests.id = help_assignments.request_id
|
||||||
|
WHERE help_assignments.status = 'completed'
|
||||||
|
GROUP BY help_assignments.helper_id
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp explain!(query) do
|
||||||
|
result =
|
||||||
|
Repo.query!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{query}", [], timeout: :infinity)
|
||||||
|
|
||||||
|
case result.rows do
|
||||||
|
[[[plan | _]]] when is_map(plan) -> plan
|
||||||
|
[[plan]] when is_map(plan) -> plan
|
||||||
|
rows -> Mix.raise("unexpected EXPLAIN JSON result: #{inspect(rows, limit: 2)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp summarize_plan(plan) do
|
||||||
|
nodes = flatten_nodes(Map.fetch!(plan, "Plan"))
|
||||||
|
|
||||||
|
%{
|
||||||
|
planning_time_ms: plan["Planning Time"],
|
||||||
|
execution_time_ms: plan["Execution Time"],
|
||||||
|
node_types: nodes |> Enum.map(& &1["Node Type"]) |> Enum.reject(&is_nil/1),
|
||||||
|
index_names: nodes |> Enum.map(& &1["Index Name"]) |> Enum.reject(&is_nil/1) |> Enum.uniq(),
|
||||||
|
shared_hit_blocks: plan["Plan"]["Shared Hit Blocks"] || 0,
|
||||||
|
shared_read_blocks: plan["Plan"]["Shared Read Blocks"] || 0,
|
||||||
|
returned_rows: plan["Plan"]["Actual Rows"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp flatten_nodes(node) do
|
||||||
|
[node | Enum.flat_map(Map.get(node, "Plans", []), &flatten_nodes/1)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp expected_indexes do
|
||||||
|
%{
|
||||||
|
"help_discovery" => "help_requests_discovery_cursor_index",
|
||||||
|
"help_requester" => "help_requests_requester_cursor_index",
|
||||||
|
"activity_discovery" => "activities_discovery_cursor_index",
|
||||||
|
"activity_participant" => "activity_participants_user_id_status_index",
|
||||||
|
"messages" => "messages_assignment_cursor_index",
|
||||||
|
"activity_messages" => "activity_messages_activity_cursor_index",
|
||||||
|
"visible_reviews" => "reviews_visible_cursor_index",
|
||||||
|
"reports_open" => "reports_cursor_index",
|
||||||
|
"reports_all" => "reports_cursor_index",
|
||||||
|
"blocks" => "blocks_blocker_cursor_index",
|
||||||
|
"abuse_signals" => "abuse_signals_status_cursor_index",
|
||||||
|
"proposals_open" => "category_proposals_cursor_index",
|
||||||
|
"proposals_all" => "category_proposals_cursor_index",
|
||||||
|
"users" => "users_moderation_cursor_index"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_expected_indexes_exist! do
|
||||||
|
existing =
|
||||||
|
Repo.query!("SELECT indexname FROM pg_indexes WHERE schemaname = current_schema()").rows
|
||||||
|
|> List.flatten()
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
missing =
|
||||||
|
expected_indexes()
|
||||||
|
|> Map.values()
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.reject(&MapSet.member?(existing, &1))
|
||||||
|
|
||||||
|
if missing != [], do: Mix.raise("missing cursor indexes: #{Enum.join(missing, ", ")}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_expected_indexes_used!(plans) do
|
||||||
|
failures =
|
||||||
|
Enum.reject(expected_indexes(), fn {name, index} ->
|
||||||
|
index in get_in(plans, [name, :index_names])
|
||||||
|
end)
|
||||||
|
|
||||||
|
if failures != [] do
|
||||||
|
details =
|
||||||
|
Enum.map_join(failures, ", ", fn {name, index} ->
|
||||||
|
"#{name} expected #{index}, observed #{inspect(get_in(plans, [name, :index_names]))}"
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mix.raise("cursor plan assertions failed: #{details}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_keyset_pages! do
|
||||||
|
{first_ids, cursor} = fetch_help_page(nil)
|
||||||
|
{second_ids, _cursor} = fetch_help_page(cursor)
|
||||||
|
|
||||||
|
if length(first_ids) != 25 or length(second_ids) != 25 do
|
||||||
|
Mix.raise("keyset page assertion expected two full 25-row pages")
|
||||||
|
end
|
||||||
|
|
||||||
|
if MapSet.disjoint?(MapSet.new(first_ids), MapSet.new(second_ids)) == false do
|
||||||
|
Mix.raise("keyset page assertion found duplicate rows across pages")
|
||||||
|
end
|
||||||
|
|
||||||
|
expected =
|
||||||
|
Repo.query!("""
|
||||||
|
SELECT id FROM help_requests
|
||||||
|
WHERE requester_id = md5('user-1')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 50
|
||||||
|
""").rows
|
||||||
|
|> List.flatten()
|
||||||
|
|
||||||
|
if first_ids ++ second_ids != expected do
|
||||||
|
Mix.raise("keyset page assertion found a gap or order mismatch")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_help_page(nil) do
|
||||||
|
result =
|
||||||
|
Repo.query!("""
|
||||||
|
SELECT id, inserted_at FROM help_requests
|
||||||
|
WHERE requester_id = md5('user-1')::uuid
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""")
|
||||||
|
|
||||||
|
page_result(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_help_page({inserted_at, id}) do
|
||||||
|
result =
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
SELECT id, inserted_at FROM help_requests
|
||||||
|
WHERE requester_id = md5('user-1')::uuid
|
||||||
|
AND (inserted_at, id) < ($1, $2)
|
||||||
|
ORDER BY inserted_at DESC, id DESC LIMIT 25
|
||||||
|
""",
|
||||||
|
[inserted_at, id]
|
||||||
|
)
|
||||||
|
|
||||||
|
page_result(result)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp page_result(result) do
|
||||||
|
ids = Enum.map(result.rows, &hd/1)
|
||||||
|
[id, inserted_at] = List.last(result.rows)
|
||||||
|
{ids, {inserted_at, id}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_seed_size!(rows) do
|
||||||
|
actual = scalar!("SELECT count(*) FROM help_requests")
|
||||||
|
if actual != rows, do: Mix.raise("expected #{rows} seeded help requests, found #{actual}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp table_counts do
|
||||||
|
~w(
|
||||||
|
users help_requests help_assignments messages reviews reports blocks
|
||||||
|
abuse_signals category_proposals activities activity_participants activity_messages
|
||||||
|
)
|
||||||
|
|> Map.new(fn table -> {table, scalar!("SELECT count(*) FROM #{table}")} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp index_sizes do
|
||||||
|
Repo.query!("""
|
||||||
|
SELECT indexrelname, pg_relation_size(indexrelid)
|
||||||
|
FROM pg_stat_user_indexes
|
||||||
|
WHERE indexrelname LIKE '%cursor_index'
|
||||||
|
ORDER BY indexrelname
|
||||||
|
""").rows
|
||||||
|
|> Map.new(fn [name, bytes] -> {name, bytes} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write_comparison!(output, after_summary) do
|
||||||
|
before_path = Path.join(output, "before-summary.json")
|
||||||
|
|
||||||
|
before =
|
||||||
|
before_path
|
||||||
|
|> File.read!()
|
||||||
|
|> Jason.decode!()
|
||||||
|
|
||||||
|
comparisons =
|
||||||
|
Map.new(after_summary.plans, fn {name, after_plan} ->
|
||||||
|
before_plan = get_in(before, ["plans", name])
|
||||||
|
|
||||||
|
{name,
|
||||||
|
%{
|
||||||
|
before_execution_time_ms: before_plan["execution_time_ms"],
|
||||||
|
after_execution_time_ms: after_plan.execution_time_ms,
|
||||||
|
observed_execution_ratio:
|
||||||
|
ratio(before_plan["execution_time_ms"], after_plan.execution_time_ms),
|
||||||
|
before_index_names: before_plan["index_names"],
|
||||||
|
after_index_names: after_plan.index_names
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
comparison = %{
|
||||||
|
note:
|
||||||
|
"Observed timings describe this isolated run and are not a portable minimum or latency gate.",
|
||||||
|
configured_rows_per_large_table: after_summary.configured_rows_per_large_table,
|
||||||
|
plans: comparisons
|
||||||
|
}
|
||||||
|
|
||||||
|
File.write!(
|
||||||
|
Path.join(output, "comparison.json"),
|
||||||
|
Jason.encode_to_iodata!(comparison, pretty: true)
|
||||||
|
)
|
||||||
|
|
||||||
|
markdown =
|
||||||
|
[
|
||||||
|
"# Isolated database scale measurement\n\n",
|
||||||
|
"Rows configured per large table: ",
|
||||||
|
Integer.to_string(after_summary.configured_rows_per_large_table),
|
||||||
|
"\n\n",
|
||||||
|
"Timings below are observations from this run, not a portable minimum or an SLO.\n\n",
|
||||||
|
"| Query | Before, ms | After, ms | Before indexes | After indexes |\n",
|
||||||
|
"| --- | ---: | ---: | --- | --- |\n",
|
||||||
|
Enum.map(comparisons, fn {name, values} ->
|
||||||
|
[
|
||||||
|
"| ",
|
||||||
|
name,
|
||||||
|
" | ",
|
||||||
|
format_ms(values.before_execution_time_ms),
|
||||||
|
" | ",
|
||||||
|
format_ms(values.after_execution_time_ms),
|
||||||
|
" | ",
|
||||||
|
Enum.join(values.before_index_names, ", "),
|
||||||
|
" | ",
|
||||||
|
Enum.join(values.after_index_names, ", "),
|
||||||
|
" |\n"
|
||||||
|
]
|
||||||
|
end)
|
||||||
|
]
|
||||||
|
|
||||||
|
File.write!(Path.join(output, "README.md"), markdown)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ratio(before_ms, after_ms)
|
||||||
|
when is_number(before_ms) and is_number(after_ms) and after_ms > 0,
|
||||||
|
do: Float.round(before_ms / after_ms, 3)
|
||||||
|
|
||||||
|
defp ratio(_before_ms, _after_ms), do: nil
|
||||||
|
|
||||||
|
defp format_ms(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 3)
|
||||||
|
defp format_ms(value), do: to_string(value)
|
||||||
|
|
||||||
|
defp scalar!(sql) do
|
||||||
|
case Repo.query!(sql).rows do
|
||||||
|
[[value]] -> value
|
||||||
|
rows -> Mix.raise("expected one scalar value, got: #{inspect(rows)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp query!(sql), do: Repo.query!(sql, [], timeout: :infinity)
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier}
|
alias WhoNeedHelp.Accounts.{Scope, SocialIdentity, User, UserToken, UserNotifier}
|
||||||
|
|
@ -102,12 +103,22 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
def admin_authorized?(_user), do: false
|
def admin_authorized?(_user), do: false
|
||||||
|
|
||||||
def list_users_for_moderation(%Scope{user: user}) do
|
def list_users_for_moderation(%Scope{user: user}) do
|
||||||
|
paginate_users_for_moderation(%Scope{user: user}).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
|
||||||
if moderator_authorized?(user) do
|
if moderator_authorized?(user) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
User
|
User
|
||||||
|> order_by([user], asc: user.moderation_status, desc: user.inserted_at)
|
|> before_moderation_user(cursor)
|
||||||
|
|> order_by([user], desc: user.inserted_at, desc: user.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
[]
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -445,6 +456,17 @@ defmodule WhoNeedHelp.Accounts do
|
||||||
|
|
||||||
## Token helper
|
## Token helper
|
||||||
|
|
||||||
|
defp before_moderation_user(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_moderation_user(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[user],
|
||||||
|
user.inserted_at < ^inserted_at or
|
||||||
|
(user.inserted_at == ^inserted_at and user.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp update_user_and_delete_all_tokens(changeset) do
|
defp update_user_and_delete_all_tokens(changeset) do
|
||||||
Repo.transact(fn ->
|
Repo.transact(fn ->
|
||||||
with {:ok, user} <- Repo.update(changeset) do
|
with {:ok, user} <- Repo.update(changeset) do
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ defmodule WhoNeedHelp.Activities do
|
||||||
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
alias WhoNeedHelp.Activities.{Activity, Message, Participant}
|
||||||
alias WhoNeedHelp.Catalog
|
alias WhoNeedHelp.Catalog
|
||||||
alias WhoNeedHelp.Catalog.Category
|
alias WhoNeedHelp.Catalog.Category
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
alias WhoNeedHelp.Trust.Block
|
alias WhoNeedHelp.Trust.Block
|
||||||
|
|
@ -28,7 +29,13 @@ defmodule WhoNeedHelp.Activities do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_open_activities(%Scope{user: user}, filters \\ %{}) do
|
def list_open_activities(%Scope{user: user}, filters \\ %{}) do
|
||||||
|
paginate_open_activities(%Scope{user: user}, filters).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_open_activities(%Scope{user: user}, filters \\ %{}, options \\ []) do
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Activity
|
Activity
|
||||||
|> where(
|
|> where(
|
||||||
|
|
@ -49,28 +56,68 @@ defmodule WhoNeedHelp.Activities do
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|
|> maybe_filter_category(filters["category_id"] || filters[:category_id])
|
||||||
|> order_by([activity], asc: activity.starts_at)
|
|> 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)
|
|> preload(category: :parent, creator: :social_identities, participants: :user)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Enum.map(&public_activity/1)
|
|> Enum.map(&public_activity/1)
|
||||||
|
|> Pagination.page(limit, &{&1.starts_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_my_activities(%Scope{user: user}) do
|
def list_my_activities(%Scope{user: user}) do
|
||||||
participant_ids =
|
paginate_my_activities(%Scope{user: user}).entries
|
||||||
from participant in Participant,
|
end
|
||||||
where:
|
|
||||||
participant.user_id == ^user.id and
|
def paginate_my_activities(%Scope{user: user}, options \\ []) do
|
||||||
participant.status in [:requested, :approved],
|
limit = Pagination.limit(options)
|
||||||
select: participant.activity_id
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Activity
|
Activity
|
||||||
|> where(
|
|> join(:inner, [activity], participant in Participant,
|
||||||
[activity],
|
on:
|
||||||
activity.creator_id == ^user.id or activity.id in subquery(participant_ids)
|
participant.activity_id == activity.id and participant.user_id == ^user.id and
|
||||||
|
participant.status in [:requested, :approved]
|
||||||
)
|
)
|
||||||
|> order_by([activity], desc: activity.starts_at)
|
|> before_my_activity(cursor)
|
||||||
|
|> order_by([activity], desc: activity.starts_at, desc: activity.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload(category: :parent, participants: :user)
|
|> preload(category: :parent, participants: :user)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.starts_at, &1.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_open_activity(query, nil), do: query
|
||||||
|
|
||||||
|
defp after_open_activity(query, {starts_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[activity],
|
||||||
|
activity.starts_at > ^starts_at or
|
||||||
|
(activity.starts_at == ^starts_at and activity.id > ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_my_activity(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_my_activity(query, {starts_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[activity],
|
||||||
|
activity.starts_at < ^starts_at or
|
||||||
|
(activity.starts_at == ^starts_at and activity.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_message(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_message(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[message],
|
||||||
|
message.inserted_at < ^inserted_at or
|
||||||
|
(message.inserted_at == ^inserted_at and message.id < ^id)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_activity(%Scope{user: user}, id) do
|
def get_activity(%Scope{user: user}, id) do
|
||||||
|
|
@ -103,6 +150,27 @@ defmodule WhoNeedHelp.Activities do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def paginate_messages(%Scope{user: user}, %Activity{} = activity, options \\ []) do
|
||||||
|
if activity.creator_id == user.id or approved_participant?(activity, user.id) do
|
||||||
|
limit = Pagination.limit(options, 50)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
page =
|
||||||
|
Message
|
||||||
|
|> where([message], message.activity_id == ^activity.id)
|
||||||
|
|> before_message(cursor)
|
||||||
|
|> order_by([message], desc: message.inserted_at, desc: message.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|
|> preload(:sender)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
|
|
||||||
|
%{page | entries: Enum.reverse(page.entries)}
|
||||||
|
else
|
||||||
|
%Pagination.Page{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def change_activity(%Activity{} = activity, attrs \\ %{}) do
|
def change_activity(%Activity{} = activity, attrs \\ %{}) do
|
||||||
activity
|
activity
|
||||||
|> Activity.create_changeset(attrs)
|
|> Activity.create_changeset(attrs)
|
||||||
|
|
@ -491,13 +559,19 @@ defmodule WhoNeedHelp.Activities do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_activity(id) do
|
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
|
Activity
|
||||||
|> Repo.get!(id)
|
|> Repo.get!(id)
|
||||||
|> Repo.preload(
|
|> Repo.preload(
|
||||||
category: :parent,
|
category: :parent,
|
||||||
creator: :social_identities,
|
creator: :social_identities,
|
||||||
participants: [user: :social_identities],
|
participants: [user: :social_identities],
|
||||||
messages: :sender
|
messages: recent_messages
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
alias WhoNeedHelp.Accounts
|
alias WhoNeedHelp.Accounts
|
||||||
alias WhoNeedHelp.Accounts.Scope
|
alias WhoNeedHelp.Accounts.Scope
|
||||||
alias WhoNeedHelp.Catalog.{Category, CategoryProposal, CategoryVote}
|
alias WhoNeedHelp.Catalog.{Category, CategoryProposal, CategoryVote}
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
|
|
||||||
|
|
@ -33,11 +34,21 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
def category_path(%Category{} = category, locale), do: Category.name(category, locale)
|
def category_path(%Category{} = category, locale), do: Category.name(category, locale)
|
||||||
|
|
||||||
def list_proposals do
|
def list_proposals do
|
||||||
|
paginate_proposals().entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_proposals(options \\ []) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
CategoryProposal
|
CategoryProposal
|
||||||
|> where([p], p.status == :open)
|
|> where([p], p.status == :open)
|
||||||
|> preload([:proposer, :parent, :votes])
|
|> preload([:proposer, :parent, :votes])
|
||||||
|> order_by([p], desc: p.inserted_at)
|
|> before_proposal(cursor)
|
||||||
|
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
def change_proposal(%CategoryProposal{} = proposal, attrs \\ %{}) do
|
def change_proposal(%CategoryProposal{} = proposal, attrs \\ %{}) do
|
||||||
|
|
@ -140,13 +151,23 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
def validate_structured_data(_category, _data), do: {:error, ["must be an object"]}
|
def validate_structured_data(_category, _data), do: {:error, ["must be an object"]}
|
||||||
|
|
||||||
def list_proposals_for_moderation(%Scope{user: user}) do
|
def list_proposals_for_moderation(%Scope{user: user}) do
|
||||||
|
paginate_proposals_for_moderation(%Scope{user: user}).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_proposals_for_moderation(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.moderator_authorized?(user) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
CategoryProposal
|
CategoryProposal
|
||||||
|> order_by([proposal], asc: proposal.status, desc: proposal.inserted_at)
|
|> before_proposal(cursor)
|
||||||
|
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes])
|
|> preload([:proposer, :parent, :merged_into, :reviewed_by, :votes])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
[]
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -812,6 +833,17 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp before_proposal(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_proposal(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[proposal],
|
||||||
|
proposal.inserted_at < ^inserted_at or
|
||||||
|
(proposal.inserted_at == ^inserted_at and proposal.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
|
defp names(en, uk, ru), do: %{"en" => en, "uk" => uk, "ru" => ru}
|
||||||
|
|
||||||
defp option(value, label), do: %{"value" => value, "label" => label}
|
defp option(value, label), do: %{"value" => value, "label" => label}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule WhoNeedHelp.Help do
|
||||||
alias WhoNeedHelp.Catalog
|
alias WhoNeedHelp.Catalog
|
||||||
alias WhoNeedHelp.Catalog.Category
|
alias WhoNeedHelp.Catalog.Category
|
||||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
alias WhoNeedHelp.Trust.Block
|
alias WhoNeedHelp.Trust.Block
|
||||||
|
|
@ -26,7 +27,13 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_open_requests(%Scope{user: user}, filters \\ %{}) do
|
def list_open_requests(%Scope{user: user}, filters \\ %{}) do
|
||||||
|
paginate_open_requests(%Scope{user: user}, filters).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
HelpRequest
|
HelpRequest
|
||||||
|> where(
|
|> where(
|
||||||
|
|
@ -46,19 +53,54 @@ defmodule WhoNeedHelp.Help do
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|> filter_open_requests(filters)
|
|> filter_open_requests(filters)
|
||||||
|> order_by([r], asc: r.expires_at)
|
|> after_open_request(cursor)
|
||||||
|
|> order_by([request], asc: request.expires_at, asc: request.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload([:category, :requester, assignment: :helper])
|
|> preload([:category, :requester, assignment: :helper])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.expires_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
||||||
|
|
||||||
def list_my_requests(%Scope{user: user}) do
|
def list_my_requests(%Scope{user: user}) do
|
||||||
|
paginate_my_requests(%Scope{user: user}).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_my_requests(%Scope{user: user}, options \\ []) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
HelpRequest
|
HelpRequest
|
||||||
|> where([r], r.requester_id == ^user.id)
|
|> where([r], r.requester_id == ^user.id)
|
||||||
|> order_by([r], desc: r.inserted_at)
|
|> before_my_request(cursor)
|
||||||
|
|> order_by([request], desc: request.inserted_at, desc: request.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload([:category, :requester, assignment: :helper])
|
|> preload([:category, :requester, assignment: :helper])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp after_open_request(query, nil), do: query
|
||||||
|
|
||||||
|
defp after_open_request(query, {expires_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[request],
|
||||||
|
request.expires_at > ^expires_at or
|
||||||
|
(request.expires_at == ^expires_at and request.id > ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_my_request(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_my_request(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[request],
|
||||||
|
request.inserted_at < ^inserted_at or
|
||||||
|
(request.inserted_at == ^inserted_at and request.id < ^id)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_request!(id) do
|
def get_request!(id) do
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule WhoNeedHelp.Messaging do
|
||||||
alias WhoNeedHelp.Help
|
alias WhoNeedHelp.Help
|
||||||
alias WhoNeedHelp.Help.Assignment
|
alias WhoNeedHelp.Help.Assignment
|
||||||
alias WhoNeedHelp.Messaging.Message
|
alias WhoNeedHelp.Messaging.Message
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
|
|
||||||
|
|
@ -14,15 +15,28 @@ defmodule WhoNeedHelp.Messaging do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_messages(%Scope{} = scope, %Assignment{} = assignment) do
|
def list_messages(%Scope{} = scope, %Assignment{} = assignment) do
|
||||||
|
paginate_messages(scope, assignment).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_messages(%Scope{} = scope, %Assignment{} = assignment, options \\ []) do
|
||||||
if Trust.eligible?(scope) and Help.participant?(scope, assignment) and
|
if Trust.eligible?(scope) and Help.participant?(scope, assignment) and
|
||||||
not blocked_assignment?(scope, assignment) do
|
not blocked_assignment?(scope, assignment) do
|
||||||
|
limit = Pagination.limit(options, 50)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
|
page =
|
||||||
Message
|
Message
|
||||||
|> where([m], m.assignment_id == ^assignment.id)
|
|> where([message], message.assignment_id == ^assignment.id)
|
||||||
|> order_by([m], asc: m.inserted_at)
|
|> before_message(cursor)
|
||||||
|
|> order_by([message], desc: message.inserted_at, desc: message.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload(:sender)
|
|> preload(:sender)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
|
|
||||||
|
%{page | entries: Enum.reverse(page.entries)}
|
||||||
else
|
else
|
||||||
[]
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -61,4 +75,15 @@ defmodule WhoNeedHelp.Messaging do
|
||||||
|
|
||||||
Trust.blocked_between?(user.id, counterpart_id)
|
Trust.blocked_between?(user.id, counterpart_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp before_message(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_message(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[message],
|
||||||
|
message.inserted_at < ^inserted_at or
|
||||||
|
(message.inserted_at == ^inserted_at and message.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
72
lib/who_need_help/pagination.ex
Normal file
72
lib/who_need_help/pagination.ex
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
defmodule WhoNeedHelp.Pagination do
|
||||||
|
@moduledoc """
|
||||||
|
Small keyset-pagination primitives shared by database-backed lists.
|
||||||
|
|
||||||
|
Cursors contain only the final row's UTC timestamp and UUID. They are
|
||||||
|
validated before being used as query parameters and carry no authorization
|
||||||
|
decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@default_limit 24
|
||||||
|
@maximum_limit 100
|
||||||
|
|
||||||
|
defmodule Page do
|
||||||
|
@moduledoc false
|
||||||
|
defstruct entries: [], next_cursor: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit(options, default \\ @default_limit) do
|
||||||
|
options
|
||||||
|
|> Keyword.get(:limit, default)
|
||||||
|
|> normalize_limit(default)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cursor(options) do
|
||||||
|
case decode(Keyword.get(options, :after)) do
|
||||||
|
{:ok, cursor} -> cursor
|
||||||
|
:error -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def page(rows, limit, cursor_fields) when is_function(cursor_fields, 1) do
|
||||||
|
entries = Enum.take(rows, limit)
|
||||||
|
|
||||||
|
next_cursor =
|
||||||
|
if length(rows) > limit do
|
||||||
|
entries
|
||||||
|
|> List.last()
|
||||||
|
|> cursor_fields.()
|
||||||
|
|> then(fn {timestamp, id} -> encode(timestamp, id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
%Page{entries: entries, next_cursor: next_cursor}
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(%DateTime{} = timestamp, id) do
|
||||||
|
%{"at" => DateTime.to_iso8601(timestamp), "id" => to_string(id)}
|
||||||
|
|> Jason.encode!()
|
||||||
|
|> Base.url_encode64(padding: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(nil), do: {:ok, nil}
|
||||||
|
def decode(""), do: {:ok, nil}
|
||||||
|
|
||||||
|
def decode(encoded) when is_binary(encoded) do
|
||||||
|
with {:ok, json} <- Base.url_decode64(encoded, padding: false),
|
||||||
|
{:ok, %{"at" => timestamp, "id" => id}} <- Jason.decode(json),
|
||||||
|
{:ok, timestamp, 0} <- DateTime.from_iso8601(timestamp),
|
||||||
|
{:ok, id} <- Ecto.UUID.cast(id) do
|
||||||
|
{:ok, {timestamp, id}}
|
||||||
|
else
|
||||||
|
_ -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(_encoded), do: :error
|
||||||
|
|
||||||
|
defp normalize_limit(limit, _default)
|
||||||
|
when is_integer(limit) and limit > 0 and limit <= @maximum_limit,
|
||||||
|
do: limit
|
||||||
|
|
||||||
|
defp normalize_limit(_limit, default), do: min(default, @maximum_limit)
|
||||||
|
end
|
||||||
|
|
@ -11,6 +11,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
alias WhoNeedHelp.Help
|
alias WhoNeedHelp.Help
|
||||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
alias WhoNeedHelp.Messaging.Message
|
alias WhoNeedHelp.Messaging.Message
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
||||||
|
|
||||||
|
|
@ -100,80 +101,103 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def visible_reviews(user_id) do
|
def visible_reviews(user_id) do
|
||||||
|
paginate_visible_reviews(user_id).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_visible_reviews(user_id, options \\ []) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Review
|
Review
|
||||||
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|
||||||
|> order_by([review], desc: review.inserted_at)
|
|> before_review(cursor)
|
||||||
|
|> order_by([review], desc: review.inserted_at, desc: review.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload(:reviewer)
|
|> preload(:reviewer)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
def reputation(user_id) do
|
def reputation(user_id) do
|
||||||
rows = completed_rows() |> Enum.filter(&participant?(&1, user_id))
|
helper_rows =
|
||||||
counterparts = Enum.map(rows, &counterpart(&1, user_id))
|
Assignment
|
||||||
ratings = ratings(user_id)
|
|> join(:inner, [assignment], request in HelpRequest,
|
||||||
|
on: request.id == assignment.request_id
|
||||||
%{
|
)
|
||||||
completed: length(rows),
|
|> where([assignment], assignment.status == :completed and assignment.helper_id == ^user_id)
|
||||||
unique_people: counterparts |> Enum.uniq() |> length(),
|
|> select([assignment, request], %{
|
||||||
verified_handovers: Enum.count(rows, &(not is_nil(&1.handover_verified_at))),
|
id: assignment.id,
|
||||||
|
counterpart_id: request.requester_id,
|
||||||
|
verified: not is_nil(assignment.handover_verified_at),
|
||||||
location_supported:
|
location_supported:
|
||||||
Enum.count(
|
not is_nil(assignment.proximity_observed_at) and
|
||||||
rows,
|
not is_nil(assignment.helper_movement_observed_at)
|
||||||
&(not is_nil(&1.proximity_observed_at) and movement_for_user?(&1, user_id))
|
})
|
||||||
),
|
|
||||||
rating: average(ratings)
|
requester_rows =
|
||||||
}
|
Assignment
|
||||||
|
|> join(:inner, [assignment], request in HelpRequest,
|
||||||
|
on: request.id == assignment.request_id
|
||||||
|
)
|
||||||
|
|> where(
|
||||||
|
[assignment, request],
|
||||||
|
assignment.status == :completed and request.requester_id == ^user_id and
|
||||||
|
assignment.helper_id != ^user_id
|
||||||
|
)
|
||||||
|
|> select([assignment], %{
|
||||||
|
id: assignment.id,
|
||||||
|
counterpart_id: assignment.helper_id,
|
||||||
|
verified: not is_nil(assignment.handover_verified_at),
|
||||||
|
location_supported: not is_nil(assignment.proximity_observed_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
aggregate =
|
||||||
|
helper_rows
|
||||||
|
|> union_all(^requester_rows)
|
||||||
|
|> subquery()
|
||||||
|
|> select([row], %{
|
||||||
|
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))
|
||||||
end
|
end
|
||||||
|
|
||||||
def leaderboard do
|
def leaderboard do
|
||||||
ratings_by_user =
|
paginate_leaderboard().entries
|
||||||
Review
|
end
|
||||||
|> 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()
|
def paginate_leaderboard(options \\ []) do
|
||||||
|> Enum.group_by(& &1.helper_id)
|
limit = Pagination.limit(options)
|
||||||
|> Enum.map(fn {helper_id, rows} ->
|
cursor = decode_leaderboard_cursor(Keyword.get(options, :after))
|
||||||
helper = rows |> hd() |> Map.fetch!(:helper)
|
rows = leaderboard_rows(cursor, limit)
|
||||||
|
|
||||||
supported_people =
|
page_rows = Enum.take(rows, limit)
|
||||||
rows
|
users_by_id = users_by_id(Enum.map(page_rows, & &1.helper_id))
|
||||||
|> 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()
|
|
||||||
|
|
||||||
|
entries =
|
||||||
|
Enum.map(page_rows, fn row ->
|
||||||
%{
|
%{
|
||||||
user: helper,
|
user: Map.fetch!(users_by_id, row.helper_id),
|
||||||
completed: length(rows),
|
completed: row.completed,
|
||||||
unique_people: rows |> Enum.map(& &1.requester_id) |> Enum.uniq() |> length(),
|
unique_people: row.unique_people,
|
||||||
location_supported_people: supported_people,
|
location_supported_people: row.location_supported_people,
|
||||||
verified_people: verified_people,
|
verified_people: row.verified_people,
|
||||||
rating: ratings_by_user |> Map.get(helper_id) |> decimal_average()
|
rating: decimal_average(row.rating)
|
||||||
}
|
|
||||||
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)
|
||||||
|
|
||||||
|
next_cursor =
|
||||||
|
if length(rows) > limit do
|
||||||
|
page_rows
|
||||||
|
|> List.last()
|
||||||
|
|> encode_leaderboard_cursor()
|
||||||
|
end
|
||||||
|
|
||||||
|
%Pagination.Page{entries: entries, next_cursor: next_cursor}
|
||||||
end
|
end
|
||||||
|
|
||||||
def report(%Scope{user: user} = scope, attrs) do
|
def report(%Scope{user: user} = scope, attrs) do
|
||||||
|
|
@ -197,10 +221,19 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_reports(%Scope{user: user}, status \\ nil) do
|
def list_reports(%Scope{user: user}, status \\ nil) do
|
||||||
|
paginate_reports(%Scope{user: user}, status).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_reports(%Scope{user: user}, status \\ nil, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.moderator_authorized?(user) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Report
|
Report
|
||||||
|> maybe_status(status)
|
|> maybe_status(status)
|
||||||
|> order_by([report], asc: report.status, desc: report.inserted_at)
|
|> before_report(cursor)
|
||||||
|
|> order_by([report], desc: report.inserted_at, desc: report.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload([
|
|> preload([
|
||||||
:reporter,
|
:reporter,
|
||||||
:reviewed_by,
|
:reviewed_by,
|
||||||
|
|
@ -211,8 +244,9 @@ defmodule WhoNeedHelp.Trust do
|
||||||
activity_message: :sender
|
activity_message: :sender
|
||||||
])
|
])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
[]
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -269,9 +303,11 @@ defmodule WhoNeedHelp.Trust do
|
||||||
if assignment_id do
|
if assignment_id do
|
||||||
Message
|
Message
|
||||||
|> where([message], message.assignment_id == ^assignment_id)
|
|> where([message], message.assignment_id == ^assignment_id)
|
||||||
|> order_by([message], asc: message.inserted_at)
|
|> order_by([message], desc: message.inserted_at, desc: message.id)
|
||||||
|
|> limit(200)
|
||||||
|> preload(:sender)
|
|> preload(:sender)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Enum.reverse()
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
@ -286,9 +322,11 @@ defmodule WhoNeedHelp.Trust do
|
||||||
if activity_id do
|
if activity_id do
|
||||||
ActivityMessage
|
ActivityMessage
|
||||||
|> where([message], message.activity_id == ^activity_id)
|
|> where([message], message.activity_id == ^activity_id)
|
||||||
|> order_by([message], asc: message.inserted_at)
|
|> order_by([message], desc: message.inserted_at, desc: message.id)
|
||||||
|
|> limit(200)
|
||||||
|> preload(:sender)
|
|> preload(:sender)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Enum.reverse()
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
@ -345,11 +383,21 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_blocks(%Scope{user: user}) do
|
def list_blocks(%Scope{user: user}) do
|
||||||
|
paginate_blocks(%Scope{user: user}).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_blocks(%Scope{user: user}, options \\ []) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
Block
|
Block
|
||||||
|> where([block], block.blocker_id == ^user.id)
|
|> where([block], block.blocker_id == ^user.id)
|
||||||
|> order_by([block], desc: block.inserted_at)
|
|> before_block(cursor)
|
||||||
|
|> order_by([block], desc: block.inserted_at, desc: block.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload(:blocked)
|
|> preload(:blocked)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
def blocked_between?(first_user_id, second_user_id) do
|
def blocked_between?(first_user_id, second_user_id) do
|
||||||
|
|
@ -429,14 +477,24 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_abuse_signals(%Scope{user: user}, status \\ :open) do
|
def list_abuse_signals(%Scope{user: user}, status \\ :open) do
|
||||||
|
paginate_abuse_signals(%Scope{user: user}, status).entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_abuse_signals(%Scope{user: user}, status \\ :open, options \\ []) do
|
||||||
if Accounts.moderator_authorized?(user) do
|
if Accounts.moderator_authorized?(user) do
|
||||||
|
limit = Pagination.limit(options)
|
||||||
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
AbuseSignal
|
AbuseSignal
|
||||||
|> where([signal], signal.status == ^status)
|
|> where([signal], signal.status == ^status)
|
||||||
|> order_by([signal], desc: signal.inserted_at)
|
|> before_abuse_signal(cursor)
|
||||||
|
|> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|> preload([:subject, :assignment, :reviewed_by])
|
|> preload([:subject, :assignment, :reviewed_by])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
||||||
else
|
else
|
||||||
[]
|
%Pagination.Page{}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -627,44 +685,204 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp completed_rows do
|
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
|
||||||
|
User
|
||||||
|
|> where([user], user.id in ^ids)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new(&{&1.id, &1})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp leaderboard_rows(cursor, limit) do
|
||||||
|
stats =
|
||||||
Assignment
|
Assignment
|
||||||
|> join(:inner, [assignment], request in HelpRequest, on: request.id == assignment.request_id)
|
|> join(:inner, [assignment], request in HelpRequest,
|
||||||
|> join(:inner, [assignment, _request], helper in User, on: helper.id == assignment.helper_id)
|
on: request.id == assignment.request_id
|
||||||
|
)
|
||||||
|> where([assignment], assignment.status == :completed)
|
|> where([assignment], assignment.status == :completed)
|
||||||
|> select([assignment, request, helper], %{
|
|> group_by([assignment], assignment.helper_id)
|
||||||
id: assignment.id,
|
|> select([assignment, request], %{
|
||||||
helper_id: assignment.helper_id,
|
helper_id: assignment.helper_id,
|
||||||
requester_id: request.requester_id,
|
completed: count(assignment.id),
|
||||||
handover_verified_at: assignment.handover_verified_at,
|
unique_people: count(request.requester_id, :distinct),
|
||||||
proximity_observed_at: assignment.proximity_observed_at,
|
location_supported_people:
|
||||||
helper_movement_observed_at: assignment.helper_movement_observed_at,
|
filter(
|
||||||
helper: helper
|
count(request.requester_id, :distinct),
|
||||||
|
not is_nil(assignment.proximity_observed_at) and
|
||||||
|
not is_nil(assignment.helper_movement_observed_at)
|
||||||
|
),
|
||||||
|
verified_people:
|
||||||
|
filter(
|
||||||
|
count(request.requester_id, :distinct),
|
||||||
|
not is_nil(assignment.handover_verified_at)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ratings =
|
||||||
|
Review
|
||||||
|
|> where([review], not is_nil(review.revealed_at))
|
||||||
|
|> group_by([review], review.reviewee_id)
|
||||||
|
|> select([review], %{reviewee_id: review.reviewee_id, rating: avg(review.rating)})
|
||||||
|
|
||||||
|
stats
|
||||||
|
|> subquery()
|
||||||
|
|> join(:inner, [stats], helper in User, on: helper.id == stats.helper_id)
|
||||||
|
|> join(:left, [stats, _helper], rating in subquery(ratings),
|
||||||
|
on: rating.reviewee_id == stats.helper_id
|
||||||
|
)
|
||||||
|
|> where(^leaderboard_after(cursor))
|
||||||
|
|> order_by(
|
||||||
|
[stats, helper],
|
||||||
|
desc: stats.location_supported_people,
|
||||||
|
desc: stats.verified_people,
|
||||||
|
desc: stats.unique_people,
|
||||||
|
desc: stats.completed,
|
||||||
|
asc: fragment("coalesce(lower(?), '')", helper.display_name),
|
||||||
|
asc: helper.id
|
||||||
|
)
|
||||||
|
|> limit(^(limit + 1))
|
||||||
|
|> select([stats, helper, rating], %{
|
||||||
|
helper_id: helper.id,
|
||||||
|
completed: stats.completed,
|
||||||
|
unique_people: stats.unique_people,
|
||||||
|
location_supported_people: stats.location_supported_people,
|
||||||
|
verified_people: stats.verified_people,
|
||||||
|
rating: rating.rating,
|
||||||
|
normalized_name: fragment("coalesce(lower(?), '')", helper.display_name)
|
||||||
})
|
})
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp participant?(row, user_id), do: row.helper_id == user_id or row.requester_id == user_id
|
defp leaderboard_after(nil), do: dynamic(true)
|
||||||
|
|
||||||
defp counterpart(row, user_id),
|
defp leaderboard_after(cursor) do
|
||||||
do: if(row.helper_id == user_id, do: row.requester_id, else: row.helper_id)
|
dynamic(
|
||||||
|
[stats, helper],
|
||||||
defp movement_for_user?(row, user_id) do
|
stats.location_supported_people < ^cursor.location_supported_people or
|
||||||
if row.helper_id == user_id, do: not is_nil(row.helper_movement_observed_at), else: true
|
(stats.location_supported_people == ^cursor.location_supported_people and
|
||||||
|
stats.verified_people < ^cursor.verified_people) or
|
||||||
|
(stats.location_supported_people == ^cursor.location_supported_people and
|
||||||
|
stats.verified_people == ^cursor.verified_people and
|
||||||
|
stats.unique_people < ^cursor.unique_people) or
|
||||||
|
(stats.location_supported_people == ^cursor.location_supported_people and
|
||||||
|
stats.verified_people == ^cursor.verified_people and
|
||||||
|
stats.unique_people == ^cursor.unique_people and stats.completed < ^cursor.completed) or
|
||||||
|
(stats.location_supported_people == ^cursor.location_supported_people and
|
||||||
|
stats.verified_people == ^cursor.verified_people and
|
||||||
|
stats.unique_people == ^cursor.unique_people and stats.completed == ^cursor.completed and
|
||||||
|
fragment("coalesce(lower(?), '')", helper.display_name) > ^cursor.normalized_name) or
|
||||||
|
(stats.location_supported_people == ^cursor.location_supported_people and
|
||||||
|
stats.verified_people == ^cursor.verified_people and
|
||||||
|
stats.unique_people == ^cursor.unique_people and stats.completed == ^cursor.completed and
|
||||||
|
fragment("coalesce(lower(?), '')", helper.display_name) == ^cursor.normalized_name and
|
||||||
|
helper.id > ^cursor.helper_id)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ratings(user_id) do
|
defp encode_leaderboard_cursor(row) do
|
||||||
Review
|
%{
|
||||||
|> where([review], review.reviewee_id == ^user_id and not is_nil(review.revealed_at))
|
"location_supported_people" => row.location_supported_people,
|
||||||
|> select([review], review.rating)
|
"verified_people" => row.verified_people,
|
||||||
|> Repo.all()
|
"unique_people" => row.unique_people,
|
||||||
|
"completed" => row.completed,
|
||||||
|
"normalized_name" => row.normalized_name,
|
||||||
|
"helper_id" => row.helper_id
|
||||||
|
}
|
||||||
|
|> Jason.encode!()
|
||||||
|
|> Base.url_encode64(padding: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp average([]), do: nil
|
defp decode_leaderboard_cursor(value) when value in [nil, ""], do: nil
|
||||||
defp average(values), do: Float.round(Enum.sum(values) / length(values), 1)
|
|
||||||
|
defp decode_leaderboard_cursor(value) when is_binary(value) do
|
||||||
|
with {:ok, json} <- Base.url_decode64(value, padding: false),
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"location_supported_people" => location_supported_people,
|
||||||
|
"verified_people" => verified_people,
|
||||||
|
"unique_people" => unique_people,
|
||||||
|
"completed" => completed,
|
||||||
|
"normalized_name" => normalized_name,
|
||||||
|
"helper_id" => helper_id
|
||||||
|
}} <- Jason.decode(json),
|
||||||
|
true <-
|
||||||
|
Enum.all?(
|
||||||
|
[location_supported_people, verified_people, unique_people, completed],
|
||||||
|
&(is_integer(&1) and &1 >= 0)
|
||||||
|
),
|
||||||
|
true <- is_binary(normalized_name) and byte_size(normalized_name) <= 80,
|
||||||
|
{:ok, helper_id} <- Ecto.UUID.cast(helper_id) do
|
||||||
|
%{
|
||||||
|
location_supported_people: location_supported_people,
|
||||||
|
verified_people: verified_people,
|
||||||
|
unique_people: unique_people,
|
||||||
|
completed: completed,
|
||||||
|
normalized_name: normalized_name,
|
||||||
|
helper_id: helper_id
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_leaderboard_cursor(_value), do: nil
|
||||||
|
|
||||||
defp decimal_average(nil), do: nil
|
defp decimal_average(nil), do: nil
|
||||||
defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1)
|
defp decimal_average(value), do: value |> Decimal.to_float() |> Float.round(1)
|
||||||
|
|
||||||
|
defp before_review(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_review(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[review],
|
||||||
|
review.inserted_at < ^inserted_at or
|
||||||
|
(review.inserted_at == ^inserted_at and review.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_report(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_report(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[report],
|
||||||
|
report.inserted_at < ^inserted_at or
|
||||||
|
(report.inserted_at == ^inserted_at and report.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_block(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_block(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[block],
|
||||||
|
block.inserted_at < ^inserted_at or
|
||||||
|
(block.inserted_at == ^inserted_at and block.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp before_abuse_signal(query, nil), do: query
|
||||||
|
|
||||||
|
defp before_abuse_signal(query, {inserted_at, id}) do
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[signal],
|
||||||
|
signal.inserted_at < ^inserted_at or
|
||||||
|
(signal.inserted_at == ^inserted_at and signal.id < ^id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
|
defp authorize_report_target(%Scope{user: user}, %{"request_id" => request_id})
|
||||||
when is_binary(request_id) do
|
when is_binary(request_id) do
|
||||||
case Repo.get(HelpRequest, request_id) do
|
case Repo.get(HelpRequest, request_id) do
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,61 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{:noreply, socket |> assign(:filters, filters) |> load()}
|
{:noreply, socket |> assign(:filters, filters) |> load()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-activities", _params, socket) do
|
||||||
|
page =
|
||||||
|
Activities.paginate_open_activities(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters,
|
||||||
|
after: socket.assigns.activities_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
activities = append_unique(socket.assigns.activities, page.entries)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:activities, activities)
|
||||||
|
|> assign(:activities_cursor, page.next_cursor)
|
||||||
|
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-my-activities", _params, socket) do
|
||||||
|
page =
|
||||||
|
Activities.paginate_my_activities(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.my_activities_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:my_activities, append_unique(socket.assigns.my_activities, page.entries))
|
||||||
|
|> assign(:my_activities_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
activities =
|
activities_page =
|
||||||
Activities.list_open_activities(socket.assigns.current_scope, socket.assigns.filters)
|
Activities.paginate_open_activities(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters
|
||||||
|
)
|
||||||
|
|
||||||
|
my_activities_page = Activities.paginate_my_activities(socket.assigns.current_scope)
|
||||||
|
activities = activities_page.entries
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Activities"))
|
|> assign(:page_title, gettext("Activities"))
|
||||||
|> assign(:activities, activities)
|
|> assign(:activities, activities)
|
||||||
|> assign(:my_activities, Activities.list_my_activities(socket.assigns.current_scope))
|
|> assign(:activities_cursor, activities_page.next_cursor)
|
||||||
|
|> assign(:my_activities, my_activities_page.entries)
|
||||||
|
|> assign(:my_activities_cursor, my_activities_page.next_cursor)
|
||||||
|> assign(:categories, Catalog.list_categories(:activity))
|
|> assign(:categories, Catalog.list_categories(:activity))
|
||||||
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
||||||
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))
|
|> assign(:markers, Jason.encode!(Enum.flat_map(activities, &List.wrap(marker(&1)))))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp append_unique(existing, incoming) do
|
||||||
|
existing_ids = MapSet.new(existing, & &1.id)
|
||||||
|
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
end
|
||||||
|
|
||||||
defp marker(activity) do
|
defp marker(activity) do
|
||||||
case Activity.public_coordinates(activity) do
|
case Activity.public_coordinates(activity) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
@ -151,6 +193,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</.link>
|
</.link>
|
||||||
|
<button
|
||||||
|
:if={@activities_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-activities"
|
||||||
|
class="btn btn-outline w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside>
|
<aside>
|
||||||
|
|
@ -176,6 +226,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
<div class="mt-2 font-semibold">{activity.title}</div>
|
<div class="mt-2 font-semibold">{activity.title}</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@my_activities_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-my-activities"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,19 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_info({:activity_message, message}, socket) do
|
||||||
|
activity = socket.assigns.activity
|
||||||
|
messages = append_message(activity.messages, message)
|
||||||
|
{:noreply, assign(socket, :activity, %{activity | messages: messages})}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({event, _payload}, socket)
|
def handle_info({event, _payload}, socket)
|
||||||
when event in [
|
when event in [
|
||||||
:activity_updated,
|
:activity_updated,
|
||||||
:join_requested,
|
:join_requested,
|
||||||
:participant_approved,
|
:participant_approved,
|
||||||
:participant_declined,
|
:participant_declined,
|
||||||
:participant_left,
|
:participant_left
|
||||||
:activity_message
|
|
||||||
] do
|
] do
|
||||||
{:noreply, reload(socket)}
|
{:noreply, reload(socket)}
|
||||||
end
|
end
|
||||||
|
|
@ -74,14 +79,38 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
socket.assigns.activity.id,
|
socket.assigns.activity.id,
|
||||||
params
|
params
|
||||||
) do
|
) do
|
||||||
{:ok, _message} ->
|
{:ok, message} ->
|
||||||
{:noreply, reload(socket)}
|
activity = socket.assigns.activity
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
assign(socket, :activity, %{
|
||||||
|
activity
|
||||||
|
| messages: append_message(activity.messages, message)
|
||||||
|
})}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, error_message(reason))}
|
{:noreply, put_flash(socket, :error, error_message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-earlier-messages", _params, socket) do
|
||||||
|
page =
|
||||||
|
Activities.paginate_messages(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.activity,
|
||||||
|
after: socket.assigns.messages_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = socket.assigns.activity
|
||||||
|
existing_ids = MapSet.new(activity.messages, & &1.id)
|
||||||
|
older = Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:activity, %{activity | messages: older ++ activity.messages})
|
||||||
|
|> assign(:messages_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("report", %{"report" => params}, socket) do
|
def handle_event("report", %{"report" => params}, socket) do
|
||||||
target =
|
target =
|
||||||
if socket.assigns.report_message_id do
|
if socket.assigns.report_message_id do
|
||||||
|
|
@ -161,6 +190,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
organizer? = activity.creator_id == user.id
|
organizer? = activity.creator_id == user.id
|
||||||
approved? = Activities.approved_participant?(activity, user.id)
|
approved? = Activities.approved_participant?(activity, user.id)
|
||||||
coordinates = Activities.coordinates_for(socket.assigns.current_scope, activity)
|
coordinates = Activities.coordinates_for(socket.assigns.current_scope, activity)
|
||||||
|
messages = Activities.paginate_messages(socket.assigns.current_scope, activity)
|
||||||
|
activity = %{activity | messages: messages.entries}
|
||||||
|
|
||||||
markers =
|
markers =
|
||||||
case coordinates do
|
case coordinates do
|
||||||
|
|
@ -180,6 +211,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, activity.title)
|
|> assign(:page_title, activity.title)
|
||||||
|> assign(:activity, activity)
|
|> assign(:activity, activity)
|
||||||
|
|> assign(:messages_cursor, messages.next_cursor)
|
||||||
|> assign(:viewer_participant, viewer_participant)
|
|> assign(:viewer_participant, viewer_participant)
|
||||||
|> assign(:organizer?, organizer?)
|
|> assign(:organizer?, organizer?)
|
||||||
|> assign(:approved?, approved?)
|
|> assign(:approved?, approved?)
|
||||||
|
|
@ -210,6 +242,10 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
|
|
||||||
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
|
defp ordered_messages(activity), do: Enum.sort_by(activity.messages, & &1.inserted_at, DateTime)
|
||||||
|
|
||||||
|
defp append_message(messages, message) do
|
||||||
|
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
|
||||||
|
end
|
||||||
|
|
||||||
defp structured_details(activity, locale) do
|
defp structured_details(activity, locale) do
|
||||||
activity.category
|
activity.category
|
||||||
|> WhoNeedHelp.Catalog.structured_fields()
|
|> WhoNeedHelp.Catalog.structured_fields()
|
||||||
|
|
@ -384,6 +420,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
|
<div id="activity-messages" class="mt-5 max-h-96 space-y-3 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
:if={@messages_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-earlier-messages"
|
||||||
|
class="btn btn-ghost btn-xs w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load earlier messages")}
|
||||||
|
</button>
|
||||||
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
|
<p :if={@activity.messages == []} class="text-sm text-base-content/50">
|
||||||
{gettext("No messages yet.")}
|
{gettext("No messages yet.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,20 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-proposals", _params, socket) do
|
||||||
|
page = Catalog.paginate_proposals(after: socket.assigns.proposals_cursor)
|
||||||
|
existing_ids = MapSet.new(socket.assigns.proposals, & &1.id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:proposals,
|
||||||
|
socket.assigns.proposals ++
|
||||||
|
Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
)
|
||||||
|
|> assign(:proposals_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
defp action_error(:account_not_eligible),
|
defp action_error(:account_not_eligible),
|
||||||
do: gettext("Confirm your account and ensure it is active first.")
|
do: gettext("Confirm your account and ensure it is active first.")
|
||||||
|
|
||||||
|
|
@ -46,9 +60,12 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
do: gettext("Could not complete the action: %{reason}", reason: inspect(reason))
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
|
proposals = Catalog.paginate_proposals()
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Category proposals"))
|
|> assign(:page_title, gettext("Category proposals"))
|
||||||
|> assign(:proposals, Catalog.list_proposals())
|
|> assign(:proposals, proposals.entries)
|
||||||
|
|> assign(:proposals_cursor, proposals.next_cursor)
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
|> assign(:form, to_form(Catalog.change_proposal(%CategoryProposal{})))
|
||||||
end
|
end
|
||||||
|
|
@ -136,6 +153,14 @@ defmodule WhoNeedHelpWeb.CategoryProposalLive do
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@proposals_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-proposals"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,28 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
page = Trust.paginate_leaderboard()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Community helpers"))
|
|> assign(:page_title, gettext("Community helpers"))
|
||||||
|> assign(:leaders, Trust.leaderboard())}
|
|> assign(:leaders, page.entries)
|
||||||
|
|> assign(:leaders_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("load-more", _params, socket) do
|
||||||
|
page = Trust.paginate_leaderboard(after: socket.assigns.leaders_cursor)
|
||||||
|
existing_ids = MapSet.new(socket.assigns.leaders, & &1.user.id)
|
||||||
|
|
||||||
|
leaders =
|
||||||
|
socket.assigns.leaders ++
|
||||||
|
Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.user.id))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:leaders, leaders)
|
||||||
|
|> assign(:leaders_cursor, page.next_cursor)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -60,6 +78,14 @@ defmodule WhoNeedHelpWeb.LeaderboardLive do
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@leaders_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more"
|
||||||
|
class="btn btn-outline mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,42 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-reports", _params, socket) do
|
||||||
|
page =
|
||||||
|
Trust.paginate_reports(socket.assigns.current_scope, nil,
|
||||||
|
after: socket.assigns.reports_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :reports, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-signals", _params, socket) do
|
||||||
|
page =
|
||||||
|
Trust.paginate_abuse_signals(socket.assigns.current_scope, :open,
|
||||||
|
after: socket.assigns.signals_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :signals, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-proposals", _params, socket) do
|
||||||
|
page =
|
||||||
|
Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.proposals_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :proposals, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-users", _params, socket) do
|
||||||
|
page =
|
||||||
|
Accounts.paginate_users_for_moderation(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.users_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :users, page)}
|
||||||
|
end
|
||||||
|
|
||||||
defp respond(socket, {:ok, _value}, message) do
|
defp respond(socket, {:ok, _value}, message) do
|
||||||
{:noreply, socket |> put_flash(:info, message) |> load()}
|
{:noreply, socket |> put_flash(:info, message) |> load()}
|
||||||
end
|
end
|
||||||
|
|
@ -155,14 +191,38 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
|
reports = Trust.paginate_reports(socket.assigns.current_scope)
|
||||||
|
signals = Trust.paginate_abuse_signals(socket.assigns.current_scope)
|
||||||
|
proposals = Catalog.paginate_proposals_for_moderation(socket.assigns.current_scope)
|
||||||
|
users = Accounts.paginate_users_for_moderation(socket.assigns.current_scope)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:reports, Trust.list_reports(socket.assigns.current_scope))
|
|> assign(:reports, reports.entries)
|
||||||
|> assign(:signals, Trust.list_abuse_signals(socket.assigns.current_scope))
|
|> assign(:reports_cursor, reports.next_cursor)
|
||||||
|> assign(:proposals, Catalog.list_proposals_for_moderation(socket.assigns.current_scope))
|
|> assign(:signals, signals.entries)
|
||||||
|> assign(:users, Accounts.list_users_for_moderation(socket.assigns.current_scope))
|
|> assign(:signals_cursor, signals.next_cursor)
|
||||||
|
|> assign(:proposals, proposals.entries)
|
||||||
|
|> assign(:proposals_cursor, proposals.next_cursor)
|
||||||
|
|> assign(:users, users.entries)
|
||||||
|
|> assign(:users_cursor, users.next_cursor)
|
||||||
|> assign(:categories, Catalog.list_all_categories())
|
|> assign(:categories, Catalog.list_all_categories())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp append_page(socket, name, page) do
|
||||||
|
existing = socket.assigns[name]
|
||||||
|
existing_ids = MapSet.new(existing, & &1.id)
|
||||||
|
entries = existing ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(name, entries)
|
||||||
|
|> assign(cursor_assign(name), page.next_cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cursor_assign(:reports), do: :reports_cursor
|
||||||
|
defp cursor_assign(:signals), do: :signals_cursor
|
||||||
|
defp cursor_assign(:proposals), do: :proposals_cursor
|
||||||
|
defp cursor_assign(:users), do: :users_cursor
|
||||||
|
|
||||||
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
defp error_message(:forbidden), do: gettext("Moderator access is required.")
|
||||||
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
defp error_message(:proposal_closed), do: gettext("This proposal has already been reviewed.")
|
||||||
|
|
||||||
|
|
@ -311,6 +371,14 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
</article>
|
</article>
|
||||||
<p :if={@reports == []} class="text-base-content/50">{gettext("No reports.")}</p>
|
<p :if={@reports == []} class="text-base-content/50">{gettext("No reports.")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@reports_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-reports"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section :if={@evidence} class="mt-8 rounded-3xl bg-neutral p-6 text-neutral-content">
|
<section :if={@evidence} class="mt-8 rounded-3xl bg-neutral p-6 text-neutral-content">
|
||||||
|
|
@ -338,6 +406,14 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
{gettext("This report has no linked conversation.")}
|
{gettext("This report has no linked conversation.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@signals_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-signals"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10">
|
<section class="mt-10">
|
||||||
|
|
@ -475,6 +551,14 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@proposals_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-proposals"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10">
|
<section class="mt-10">
|
||||||
|
|
@ -559,6 +643,14 @@ defmodule WhoNeedHelpWeb.ModerationLive do
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@users_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-users"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,17 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
user = socket.assigns.current_scope.user
|
user = socket.assigns.current_scope.user
|
||||||
|
reviews = Trust.paginate_visible_reviews(user.id)
|
||||||
|
blocks = Trust.paginate_blocks(socket.assigns.current_scope)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Profile"))
|
|> assign(:page_title, gettext("Profile"))
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|> assign(:reviews, Trust.visible_reviews(user.id))
|
|> assign(:reviews, reviews.entries)
|
||||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
|> assign(:reviews_cursor, reviews.next_cursor)
|
||||||
|
|> assign(:blocks, blocks.entries)
|
||||||
|
|> assign(:blocks_cursor, blocks.next_cursor)
|
||||||
|> assign(:github_oauth_enabled, SocialOAuth.enabled?(:github))
|
|> assign(:github_oauth_enabled, SocialOAuth.enabled?(:github))
|
||||||
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|
|> assign(:form, to_form(Accounts.change_user_profile(user)))
|
||||||
|> assign_social_identities(user)}
|
|> assign_social_identities(user)}
|
||||||
|
|
@ -67,9 +71,12 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
def handle_event("unblock", %{"id" => blocked_id}, socket) do
|
def handle_event("unblock", %{"id" => blocked_id}, socket) do
|
||||||
case Trust.unblock(socket.assigns.current_scope, blocked_id) do
|
case Trust.unblock(socket.assigns.current_scope, blocked_id) do
|
||||||
{:ok, _result} ->
|
{:ok, _result} ->
|
||||||
|
blocks = Trust.paginate_blocks(socket.assigns.current_scope)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:blocks, Trust.list_blocks(socket.assigns.current_scope))
|
|> assign(:blocks, blocks.entries)
|
||||||
|
|> assign(:blocks_cursor, blocks.next_cursor)
|
||||||
|> put_flash(:info, gettext("User unblocked."))}
|
|> put_flash(:info, gettext("User unblocked."))}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
|
|
@ -82,6 +89,36 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-reviews", _params, socket) do
|
||||||
|
page =
|
||||||
|
Trust.paginate_visible_reviews(socket.assigns.current_scope.user.id,
|
||||||
|
after: socket.assigns.reviews_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :reviews, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-blocks", _params, socket) do
|
||||||
|
page =
|
||||||
|
Trust.paginate_blocks(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.blocks_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, append_page(socket, :blocks, page)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_page(socket, name, page) do
|
||||||
|
existing = socket.assigns[name]
|
||||||
|
existing_ids = MapSet.new(existing, & &1.id)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(name, existing ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id)))
|
||||||
|
|> assign(cursor_assign(name), page.next_cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cursor_assign(:reviews), do: :reviews_cursor
|
||||||
|
defp cursor_assign(:blocks), do: :blocks_cursor
|
||||||
|
|
||||||
defp assign_social_identities(socket, user) do
|
defp assign_social_identities(socket, user) do
|
||||||
socket
|
socket
|
||||||
|> assign(:social_identities, Accounts.list_social_identities(user))
|
|> assign(:social_identities, Accounts.list_social_identities(user))
|
||||||
|
|
@ -175,6 +212,14 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
<div class="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
|
<div class="font-semibold">{"★" |> String.duplicate(review.rating)}</div>
|
||||||
<div class="mt-1">{review.comment}</div>
|
<div class="mt-1">{review.comment}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@reviews_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-reviews"
|
||||||
|
class="btn btn-outline btn-sm mt-4 w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -297,6 +342,14 @@ defmodule WhoNeedHelpWeb.ProfileLive do
|
||||||
{gettext("Unblock")}
|
{gettext("Unblock")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@blocks_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-blocks"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,60 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
{:noreply, socket |> assign(:filters, filters) |> load()}
|
{:noreply, socket |> assign(:filters, filters) |> load()}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-requests", _params, socket) do
|
||||||
|
page =
|
||||||
|
Help.paginate_open_requests(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters,
|
||||||
|
after: socket.assigns.requests_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
requests = append_unique(socket.assigns.requests, page.entries)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:requests, requests)
|
||||||
|
|> assign(:requests_cursor, page.next_cursor)
|
||||||
|
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("load-more-my-requests", _params, socket) do
|
||||||
|
page =
|
||||||
|
Help.paginate_my_requests(socket.assigns.current_scope,
|
||||||
|
after: socket.assigns.my_requests_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:my_requests, append_unique(socket.assigns.my_requests, page.entries))
|
||||||
|
|> assign(:my_requests_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
requests = Help.list_open_requests(socket.assigns.current_scope, socket.assigns.filters)
|
requests_page =
|
||||||
|
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters)
|
||||||
|
|
||||||
|
my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope)
|
||||||
|
requests = requests_page.entries
|
||||||
user = socket.assigns.current_scope.user
|
user = socket.assigns.current_scope.user
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Nearby help"))
|
|> assign(:page_title, gettext("Nearby help"))
|
||||||
|> assign(:requests, requests)
|
|> assign(:requests, requests)
|
||||||
|> assign(:my_requests, Help.list_my_requests(socket.assigns.current_scope))
|
|> assign(:requests_cursor, requests_page.next_cursor)
|
||||||
|
|> assign(:my_requests, my_requests_page.entries)
|
||||||
|
|> assign(:my_requests_cursor, my_requests_page.next_cursor)
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())
|
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())
|
||||||
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
||||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp append_unique(existing, incoming) do
|
||||||
|
existing_ids = MapSet.new(existing, & &1.id)
|
||||||
|
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
end
|
||||||
|
|
||||||
defp marker(request) do
|
defp marker(request) do
|
||||||
case Help.HelpRequest.public_coordinates(request) do
|
case Help.HelpRequest.public_coordinates(request) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
@ -178,6 +218,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</.link>
|
</.link>
|
||||||
|
<button
|
||||||
|
:if={@requests_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-requests"
|
||||||
|
class="btn btn-outline w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="space-y-5">
|
<aside class="space-y-5">
|
||||||
|
|
@ -223,6 +271,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<div class="mt-2 font-semibold">{request.title}</div>
|
<div class="mt-2 font-semibold">{request.title}</div>
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
:if={@my_requests_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-my-requests"
|
||||||
|
class="btn btn-outline btn-sm mt-4"
|
||||||
|
>
|
||||||
|
{gettext("Load more")}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:new_message, _message}, socket) do
|
def handle_info({:new_message, message}, socket) do
|
||||||
{:noreply,
|
{:noreply, assign(socket, :messages, append_message(socket.assigns.messages, message))}
|
||||||
load(socket, Help.get_request!(socket.assigns.request.id), socket.assigns.tracking_active)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:position_updated, user_id, position, evidence}, socket) do
|
def handle_info({:position_updated, user_id, position, evidence}, socket) do
|
||||||
|
|
@ -129,21 +128,34 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_event("send-message", %{"message" => params}, socket) do
|
def handle_event("send-message", %{"message" => params}, socket) do
|
||||||
case Messaging.send_message(socket.assigns.current_scope, socket.assigns.assignment, params) do
|
case Messaging.send_message(socket.assigns.current_scope, socket.assigns.assignment, params) do
|
||||||
{:ok, _} ->
|
{:ok, message} ->
|
||||||
request = Help.get_request!(socket.assigns.request.id)
|
{:noreply,
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
socket
|
||||||
|> load(request, socket.assigns.tracking_active)
|
|> assign(:messages, append_message(socket.assigns.messages, message))
|
||||||
|> push_event("reset-message-form", %{id: "message-form"})
|
|> push_event("reset-message-form", %{id: "message-form"})}
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load-earlier-messages", _params, socket) do
|
||||||
|
page =
|
||||||
|
Messaging.paginate_messages(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.assignment,
|
||||||
|
after: socket.assigns.messages_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_ids = MapSet.new(socket.assigns.messages, & &1.id)
|
||||||
|
older = Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:messages, older ++ socket.assigns.messages)
|
||||||
|
|> assign(:messages_cursor, page.next_cursor)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("start-tracking", _, socket) do
|
def handle_event("start-tracking", _, socket) do
|
||||||
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
|
|
@ -247,6 +259,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
socket
|
socket
|
||||||
|> assign(:blocked_by_current, true)
|
|> assign(:blocked_by_current, true)
|
||||||
|> assign(:messages, [])
|
|> assign(:messages, [])
|
||||||
|
|> assign(:messages_cursor, nil)
|
||||||
|> assign(:positions, %{})
|
|> assign(:positions, %{})
|
||||||
|> assign(:tracking_active, false)
|
|> assign(:tracking_active, false)
|
||||||
|> assign(
|
|> assign(
|
||||||
|
|
@ -330,10 +343,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
true -> nil
|
true -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
messages =
|
messages_page =
|
||||||
if participant,
|
if participant,
|
||||||
do: Messaging.list_messages(socket.assigns.current_scope, assignment),
|
do: Messaging.paginate_messages(socket.assigns.current_scope, assignment),
|
||||||
else: []
|
else: %WhoNeedHelp.Pagination.Page{}
|
||||||
|
|
||||||
positions =
|
positions =
|
||||||
if participant,
|
if participant,
|
||||||
|
|
@ -359,7 +372,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:blocked_by_current,
|
:blocked_by_current,
|
||||||
other_user_id && Trust.blocked_by?(current_user_id, other_user_id)
|
other_user_id && Trust.blocked_by?(current_user_id, other_user_id)
|
||||||
)
|
)
|
||||||
|> assign(:messages, messages)
|
|> assign(:messages, messages_page.entries)
|
||||||
|
|> assign(:messages_cursor, messages_page.next_cursor)
|
||||||
|> assign(:report_message_id, nil)
|
|> assign(:report_message_id, nil)
|
||||||
|> assign(:positions, positions)
|
|> assign(:positions, positions)
|
||||||
|> assign(:tracking_active, tracking_active)
|
|> assign(:tracking_active, tracking_active)
|
||||||
|
|
@ -375,6 +389,10 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp append_message(messages, message) do
|
||||||
|
if Enum.any?(messages, &(&1.id == message.id)), do: messages, else: messages ++ [message]
|
||||||
|
end
|
||||||
|
|
||||||
defp report_form do
|
defp report_form do
|
||||||
to_form(%{"reason" => "dangerous_request", "details" => ""}, as: :report)
|
to_form(%{"reason" => "dangerous_request", "details" => ""}, as: :report)
|
||||||
end
|
end
|
||||||
|
|
@ -633,6 +651,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
id="messages"
|
id="messages"
|
||||||
class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4"
|
class="mt-5 max-h-80 space-y-3 overflow-y-auto rounded-2xl bg-base-200 p-4"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
:if={@messages_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-earlier-messages"
|
||||||
|
class="btn btn-ghost btn-xs w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load earlier messages")}
|
||||||
|
</button>
|
||||||
<p :if={@messages == []} class="py-6 text-center text-sm text-base-content/50">
|
<p :if={@messages == []} class="py-6 text-center text-sm text-base-content/50">
|
||||||
{gettext("No messages yet.")}
|
{gettext("No messages yet.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,44 @@
|
||||||
|
defmodule WhoNeedHelp.Repo.Migrations.AddKeysetPaginationIndexes do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create index(:help_requests, [:expires_at, :id],
|
||||||
|
name: :help_requests_discovery_cursor_index,
|
||||||
|
where: "status = 'open' AND hidden_at IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:help_requests, [:requester_id, :inserted_at, :id],
|
||||||
|
name: :help_requests_requester_cursor_index
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:activities, [:starts_at, :id],
|
||||||
|
name: :activities_discovery_cursor_index,
|
||||||
|
where: "status = 'open' AND hidden_at IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:messages, [:assignment_id, :inserted_at, :id],
|
||||||
|
name: :messages_assignment_cursor_index
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:activity_messages, [:activity_id, :inserted_at, :id],
|
||||||
|
name: :activity_messages_activity_cursor_index
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:reviews, [:reviewee_id, :inserted_at, :id],
|
||||||
|
name: :reviews_visible_cursor_index,
|
||||||
|
where: "revealed_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:reports, [:inserted_at, :id], name: :reports_cursor_index)
|
||||||
|
|
||||||
|
create index(:blocks, [:blocker_id, :inserted_at, :id], name: :blocks_blocker_cursor_index)
|
||||||
|
|
||||||
|
create index(:abuse_signals, [:status, :inserted_at, :id],
|
||||||
|
name: :abuse_signals_status_cursor_index
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:category_proposals, [:inserted_at, :id], name: :category_proposals_cursor_index)
|
||||||
|
|
||||||
|
create index(:users, [:inserted_at, :id], name: :users_moderation_cursor_index)
|
||||||
|
end
|
||||||
|
end
|
||||||
81
scripts/db-scale-benchmark.sh
Executable file
81
scripts/db-scale-benchmark.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
row_count=${DB_SCALE_ROWS:-50000}
|
||||||
|
|
||||||
|
case "$row_count" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "DB_SCALE_ROWS must be an integer greater than or equal to 3." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$row_count" -lt 3 ]; then
|
||||||
|
echo "DB_SCALE_ROWS must be greater than or equal to 3." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||||
|
project="wnh_db_scale_$(printf '%s' "$run_id" | tr -d '-')"
|
||||||
|
image="who-need-help:db-scale-$run_id"
|
||||||
|
output="$ROOT/output/db-scale/$run_id"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
QUALITY_POSTGRES_USER="wnh_scale_$(openssl rand -hex 6)"
|
||||||
|
QUALITY_POSTGRES_PASSWORD=$(openssl rand -base64 48 | tr -d '\n')
|
||||||
|
export QUALITY_POSTGRES_USER QUALITY_POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
compose="docker compose -p $project -f $ROOT/compose.quality.yaml"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
mkdir -p "$output"
|
||||||
|
|
||||||
|
echo "Building the isolated database-scale image"
|
||||||
|
docker build --target test --tag "$image" .
|
||||||
|
|
||||||
|
echo "Starting an isolated PostgreSQL/PostGIS 18 database"
|
||||||
|
$compose up --detach --wait db
|
||||||
|
|
||||||
|
run_mix() {
|
||||||
|
docker run --rm \
|
||||||
|
--network "${project}_internal" \
|
||||||
|
--env MIX_ENV=test \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env DB_HOST=db \
|
||||||
|
--env "DB_USER=$QUALITY_POSTGRES_USER" \
|
||||||
|
--env "DB_PASSWORD=$QUALITY_POSTGRES_PASSWORD" \
|
||||||
|
--volume "$output:/benchmark-output" \
|
||||||
|
"$image" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Migrating to the pre-index baseline"
|
||||||
|
run_mix mix ecto.create
|
||||||
|
run_mix mix ecto.migrate --to 20260719004249
|
||||||
|
|
||||||
|
echo "Seeding and measuring $row_count rows per large benchmark table"
|
||||||
|
run_mix mix wnh.db_scale_benchmark \
|
||||||
|
--phase before \
|
||||||
|
--rows "$row_count" \
|
||||||
|
--output /benchmark-output
|
||||||
|
|
||||||
|
echo "Applying the generated cursor-index migration and measuring again"
|
||||||
|
run_mix mix ecto.migrate
|
||||||
|
run_mix mix wnh.db_scale_benchmark \
|
||||||
|
--phase after \
|
||||||
|
--rows "$row_count" \
|
||||||
|
--output /benchmark-output
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--volume "$output:/output" \
|
||||||
|
alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 \
|
||||||
|
chown -R "$(id -u):$(id -g)" /output
|
||||||
|
|
||||||
|
echo "Isolated before/after plans and measurements: $output"
|
||||||
|
|
@ -158,6 +158,63 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
Activities.request_to_join(context.participant_scope, activity.id)
|
Activities.request_to_join(context.participant_scope, activity.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "activity discovery uses stable cursor pages", context do
|
||||||
|
activities =
|
||||||
|
for offset <- 2..5 do
|
||||||
|
attrs =
|
||||||
|
context.attrs
|
||||||
|
|> Map.put("title", "Cursor activity #{offset}")
|
||||||
|
|> Map.put(
|
||||||
|
"starts_at",
|
||||||
|
DateTime.utc_now(:second) |> DateTime.add(offset, :hour)
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"join_deadline",
|
||||||
|
DateTime.utc_now(:second) |> DateTime.add(offset - 1, :hour)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
|
||||||
|
activity
|
||||||
|
end
|
||||||
|
|
||||||
|
first = Activities.paginate_open_activities(context.outsider_scope, %{}, limit: 2)
|
||||||
|
|
||||||
|
second =
|
||||||
|
Activities.paginate_open_activities(context.outsider_scope, %{},
|
||||||
|
limit: 2,
|
||||||
|
after: first.next_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_ids =
|
||||||
|
activities
|
||||||
|
|> Enum.sort_by(&{&1.starts_at, &1.id})
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
actual_ids = Enum.map(first.entries ++ second.entries, & &1.id)
|
||||||
|
|
||||||
|
assert actual_ids == expected_ids
|
||||||
|
assert length(Enum.uniq(actual_ids)) == 4
|
||||||
|
assert is_binary(first.next_cursor)
|
||||||
|
assert second.next_cursor == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "my activities are derived from the organizer and participant membership rows", context do
|
||||||
|
{:ok, organized} = Activities.create_activity(context.organizer_scope, context.attrs)
|
||||||
|
{:ok, request} = Activities.request_to_join(context.participant_scope, organized.id)
|
||||||
|
|
||||||
|
assert Enum.map(Activities.list_my_activities(context.organizer_scope), & &1.id) == [
|
||||||
|
organized.id
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
|
||||||
|
organized.id
|
||||||
|
]
|
||||||
|
|
||||||
|
assert {:ok, _left} = Activities.leave_activity(context.participant_scope, organized.id)
|
||||||
|
assert Activities.list_my_activities(context.participant_scope) == []
|
||||||
|
assert request.status == :requested
|
||||||
|
end
|
||||||
|
|
||||||
test "activity message reports expose only the linked group to audited moderators", context do
|
test "activity message reports expose only the linked group to audited moderators", context do
|
||||||
moderator =
|
moderator =
|
||||||
user_fixture(display_name: "Activity moderator")
|
user_fixture(display_name: "Activity moderator")
|
||||||
|
|
@ -174,7 +231,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
"body" => "Meet beside the public entrance."
|
"body" => "Meet beside the public entrance."
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, _participant_message} =
|
{:ok, participant_message} =
|
||||||
Activities.send_message(context.participant_scope, activity.id, %{
|
Activities.send_message(context.participant_scope, activity.id, %{
|
||||||
"body" => "Understood."
|
"body" => "Understood."
|
||||||
})
|
})
|
||||||
|
|
@ -198,10 +255,12 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
|
|
||||||
assert evidence.messages == []
|
assert evidence.messages == []
|
||||||
|
|
||||||
assert Enum.map(evidence.activity_messages, & &1.body) == [
|
expected_message_ids =
|
||||||
"Meet beside the public entrance.",
|
[organizer_message, participant_message]
|
||||||
"Understood."
|
|> Enum.sort_by(&{&1.inserted_at, &1.id})
|
||||||
]
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
assert Enum.map(evidence.activity_messages, & &1.id) == expected_message_ids
|
||||||
|
|
||||||
assert Repo.exists?(
|
assert Repo.exists?(
|
||||||
from event in AuditEvent,
|
from event in AuditEvent,
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,89 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
|
Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "request discovery uses stable cursor pages without gaps or duplicates", context do
|
||||||
|
requests =
|
||||||
|
for offset <- 1..5 do
|
||||||
|
attrs =
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Cursor request #{offset}")
|
||||||
|
|> Map.put(
|
||||||
|
"expires_at",
|
||||||
|
DateTime.utc_now(:second) |> DateTime.add(offset, :hour)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, request} = Help.create_request(context.requester_scope, attrs)
|
||||||
|
request
|
||||||
|
end
|
||||||
|
|
||||||
|
first = Help.paginate_open_requests(context.helper_scope, %{}, limit: 2)
|
||||||
|
|
||||||
|
second =
|
||||||
|
Help.paginate_open_requests(context.helper_scope, %{},
|
||||||
|
limit: 2,
|
||||||
|
after: first.next_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
third =
|
||||||
|
Help.paginate_open_requests(context.helper_scope, %{},
|
||||||
|
limit: 2,
|
||||||
|
after: second.next_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_ids =
|
||||||
|
requests
|
||||||
|
|> Enum.sort_by(&{&1.expires_at, &1.id})
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
actual_ids = Enum.map(first.entries ++ second.entries ++ third.entries, & &1.id)
|
||||||
|
|
||||||
|
assert actual_ids == expected_ids
|
||||||
|
assert length(Enum.uniq(actual_ids)) == 5
|
||||||
|
assert is_binary(first.next_cursor)
|
||||||
|
assert is_binary(second.next_cursor)
|
||||||
|
assert third.next_cursor == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "matched chat loads newest cursor pages and preserves chronological order", context do
|
||||||
|
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||||
|
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
|
|
||||||
|
messages =
|
||||||
|
for offset <- 1..5 do
|
||||||
|
{:ok, message} =
|
||||||
|
Messaging.send_message(context.helper_scope, assignment, %{
|
||||||
|
"body" => "Cursor message #{offset}"
|
||||||
|
})
|
||||||
|
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
|
newest = Messaging.paginate_messages(context.requester_scope, assignment, limit: 2)
|
||||||
|
|
||||||
|
middle =
|
||||||
|
Messaging.paginate_messages(context.requester_scope, assignment,
|
||||||
|
limit: 2,
|
||||||
|
after: newest.next_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
oldest =
|
||||||
|
Messaging.paginate_messages(context.requester_scope, assignment,
|
||||||
|
limit: 2,
|
||||||
|
after: middle.next_cursor
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_ids =
|
||||||
|
messages
|
||||||
|
|> Enum.sort_by(&{&1.inserted_at, &1.id})
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
actual_ids = Enum.map(oldest.entries ++ middle.entries ++ newest.entries, & &1.id)
|
||||||
|
|
||||||
|
assert actual_ids == expected_ids
|
||||||
|
assert length(Enum.uniq(actual_ids)) == 5
|
||||||
|
assert oldest.next_cursor == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "stopping tracking deletes the exact current position", context do
|
test "stopping tracking deletes the exact current position", context do
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
|
|
|
||||||
32
test/who_need_help/pagination_test.exs
Normal file
32
test/who_need_help/pagination_test.exs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
defmodule WhoNeedHelp.PaginationTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Pagination
|
||||||
|
|
||||||
|
test "cursor round-trips a UTC timestamp and UUID" do
|
||||||
|
timestamp = ~U[2026-07-19 01:30:00Z]
|
||||||
|
id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:ok, {^timestamp, ^id}} = timestamp |> Pagination.encode(id) |> Pagination.decode()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid cursors never become query values" do
|
||||||
|
assert :error = Pagination.decode("not-a-cursor")
|
||||||
|
assert Pagination.cursor(after: "not-a-cursor") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "page exposes a cursor only when another row exists" do
|
||||||
|
rows = [
|
||||||
|
%{id: Ecto.UUID.generate(), inserted_at: ~U[2026-07-19 01:30:03Z]},
|
||||||
|
%{id: Ecto.UUID.generate(), inserted_at: ~U[2026-07-19 01:30:02Z]},
|
||||||
|
%{id: Ecto.UUID.generate(), inserted_at: ~U[2026-07-19 01:30:01Z]}
|
||||||
|
]
|
||||||
|
|
||||||
|
page = Pagination.page(rows, 2, &{&1.inserted_at, &1.id})
|
||||||
|
assert page.entries == Enum.take(rows, 2)
|
||||||
|
assert is_binary(page.next_cursor)
|
||||||
|
|
||||||
|
last_page = Pagination.page(Enum.take(rows, 2), 2, &{&1.inserted_at, &1.id})
|
||||||
|
assert last_page.next_cursor == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -119,6 +119,34 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
refute first.id == second.id
|
refute first.id == second.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "leaderboard cursor preserves the complete ranking without duplicates", context do
|
||||||
|
complete_supported_help(context, context.attrs)
|
||||||
|
|
||||||
|
helpers =
|
||||||
|
for {name, suffix} <- [{"Alpha helper", "alpha"}, {"Beta helper", "beta"}] do
|
||||||
|
helper = user_fixture(display_name: name)
|
||||||
|
helper_scope = user_scope_fixture(helper)
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
context.attrs
|
||||||
|
|> Map.put("title", "Medicine pickup for #{suffix}")
|
||||||
|
|
||||||
|
complete_supported_help(context, attrs, helper_scope)
|
||||||
|
helper
|
||||||
|
end
|
||||||
|
|
||||||
|
first = Trust.paginate_leaderboard(limit: 2)
|
||||||
|
second = Trust.paginate_leaderboard(limit: 2, after: first.next_cursor)
|
||||||
|
ids = Enum.map(first.entries ++ second.entries, & &1.user.id)
|
||||||
|
|
||||||
|
assert length(ids) == 3
|
||||||
|
assert length(Enum.uniq(ids)) == 3
|
||||||
|
assert context.helper.id in ids
|
||||||
|
assert Enum.all?(helpers, &(&1.id in ids))
|
||||||
|
assert first.next_cursor
|
||||||
|
assert second.next_cursor == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "reports expose only linked evidence to moderators and audit that access", context do
|
test "reports expose only linked evidence to moderators and audit that access", context do
|
||||||
moderator =
|
moderator =
|
||||||
user_fixture(display_name: "Moderator")
|
user_fixture(display_name: "Moderator")
|
||||||
|
|
@ -279,9 +307,10 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp complete_supported_help(context, attrs) do
|
defp complete_supported_help(context, attrs, helper_scope \\ nil) do
|
||||||
|
helper_scope = helper_scope || context.helper_scope
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, attrs)
|
||||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, assignment} = Help.accept_request(helper_scope, request.id)
|
||||||
|
|
||||||
assignment =
|
assignment =
|
||||||
assignment
|
assignment
|
||||||
|
|
@ -292,10 +321,10 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
|> Repo.update!()
|
|> Repo.update!()
|
||||||
|
|
||||||
{:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
|
{:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
|
||||||
{:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)
|
{:ok, _} = Help.confirm_completion(helper_scope, assignment.id)
|
||||||
|
|
||||||
{:ok, assignment} =
|
{:ok, assignment} =
|
||||||
Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))
|
Help.verify_handover(helper_scope, assignment.id, Help.handover_code(request.id))
|
||||||
|
|
||||||
assignment
|
assignment
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user