diff --git a/.circleci/config.yml b/.circleci/config.yml index 23053e2d45..6973d6f0ce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,72 +1,67 @@ version: 2.1 -jobs: - build: - machine: - image: ubuntu-2204:current - resource_class: large - environment: - - TZ: "UTC" - +commands: + install-task: steps: - - checkout - - # Install Task - run: name: Install Task command: | sudo sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin task --version - # Set up environment file + setup-env: + steps: - run: name: Setup environment command: | cp .env.example .env - + # Configure for Docker Compose setup sed -i 's/DB_HOST=.*$/DB_HOST=restarters_db/g' .env sed -i 's/DB_DATABASE=.*$/DB_DATABASE=restarters_db_test/g' .env sed -i 's/DB_USERNAME=.*$/DB_USERNAME=restarters/g' .env sed -i 's/DB_PASSWORD=.*$/DB_PASSWORD=s3cr3t/g' .env - + # Configure Discourse integration sed -i 's/FEATURE__DISCOURSE_INTEGRATION=.*$/FEATURE__DISCOURSE_INTEGRATION=true/g' .env sed -i 's/DISCOURSE_URL=.*$/DISCOURSE_URL=http:\/\/restarters_discourse/g' .env sed -i 's/DISCOURSE_APIKEY=.*$/DISCOURSE_APIKEY=fb71f38ca2b8b7cd6a041e57fd8202c9937088f0ecae7db40722bd758dda92fc/g' .env sed -i 's/DISCOURSE_APIUSER=.*$/DISCOURSE_APIUSER=someuser/g' .env - + # Configure for testing sed -i 's/APP_DEBUG=.*$/APP_DEBUG=false/g' .env sed -i 's/SESSION_DOMAIN=.*$/SESSION_DOMAIN=localhost/g' .env sed -i 's/HONEYPOT_DISABLE=.*$/HONEYPOT_DISABLE=TRUE/g' .env sed -i 's/APP_URL=.*$/APP_URL=http:\/\/localhost:8001/g' .env - - # Add environment variables from CircleCI + + # The Nuxt client origin (SSO bridge redirects, session config). echo "" >> .env + echo "FRONTEND_URL=http://localhost:3000" >> .env + + # The mysql_testing connection (used by the Taskfile's explicit + # pre-phpunit migrate:fresh). phpunit.xml sets these inside the + # phpunit process, but plain artisan reads .env — without them + # DB_TEST_HOST falls back to 127.0.0.1 and the reset fails. + echo "DB_TEST_HOST=restarters_db" >> .env + echo "DB_TEST_PORT=3306" >> .env + echo "DB_TEST_DATABASE=restarters_db_test" >> .env + echo "DB_TEST_USERNAME=restarters" >> .env + echo "DB_TEST_PASSWORD=s3cr3t" >> .env + + # Add environment variables from CircleCI echo "GOOGLE_API_CONSOLE_KEY=$GOOGLE_API_CONSOLE_KEY" >> .env echo "MAPBOX_TOKEN=$MAPBOX_TOKEN" >> .env - # Start Docker services using Task - - run: - name: Start Docker services - command: | - # Set environment variable for CircleCI detection - export CIRCLECI=true - # Enable Docker Compose bake for build optimization - export COMPOSE_BAKE=true - # Start all services using Task - task docker:up-all - no_output_timeout: 10m - - # Wait for core services to be ready (MySQL + web app). Discourse is started - # via docker:up-all but its readiness is checked separately (non-blocking). - # docker_run.sh runs npm install + npx playwright install + migrate:fresh --seed - # on every startup, which can take 20-25 min on cold CI machines. We poll - # directly here with a 25-minute ceiling rather than using the Taskfile helper - # (which only allows 10 min) to avoid blocking on that limit. + wait-for-core: + parameters: + probe: + type: string + # Blade homepage by default; jobs with SKIP_NPM_INSTALL must probe an + # API route instead (no Vite manifest -> Blade pages 500). + default: "http://localhost:8001" + steps: - run: - name: Wait for services + name: Wait for core services command: | # Wait for MySQL first echo "Waiting for MySQL..." @@ -89,7 +84,7 @@ jobs: docker logs restarters --tail=100 2>&1 || true exit 1 fi - curl -f -s http://localhost:8001 >/dev/null 2>&1 && echo "✓ Web app ready after $((i*5))s" && break + curl -f -s << parameters.probe >> >/dev/null 2>&1 && echo "✓ Web app ready after $((i*5))s" && break if [ $i -eq 420 ]; then echo "❌ Web app not ready after 35 minutes — container logs:" docker logs restarters --tail=100 2>&1 || true @@ -100,6 +95,44 @@ jobs: done no_output_timeout: 38m +jobs: + build: + machine: + image: ubuntu-2204:current + resource_class: large + environment: + - TZ: "UTC" + + steps: + - checkout + - install-task + - setup-env + + # Start Docker services using Task + - run: + name: Start Docker services + command: | + # Set environment variable for CircleCI detection + export CIRCLECI=true + # Enable Docker Compose bake for build optimization + export COMPOSE_BAKE=true + # phpunit-only job: skip the ~10-12 min npm/Vite container startup + # (the suite outgrew CircleCI's 60-min job cap as one monolith; + # the legacy jest+Playwright suites died at the Phase F cutover). + export SKIP_NPM_INSTALL=true + # Start all services using Task + task docker:up-all + # This job tests the Laravel side only; the Nuxt client container + # is only needed by e2e-client. + docker stop restarters_client || true + no_output_timeout: 10m + + # Wait for core services to be ready (MySQL + web app). Discourse is started + # via docker:up-all but its readiness is checked separately (non-blocking). + # Probe an API route: SKIP_NPM_INSTALL means Blade pages 500 (no Vite manifest). + - wait-for-core: + probe: "http://localhost:8001/api/v2/session" + # Setup database and application - run: name: Setup application @@ -116,6 +149,15 @@ jobs: # Generate additional Laravel artifacts for testing docker exec restarters php artisan l5-swagger:generate + # Fail fast on a broken OpenAPI document (dangling refs, orphaned + # security schemes, duplicate operationIds) before running tests. + docker exec restarters php tools/openapi-lint.php + + # The committed client locale JSONs must match a fresh export from lang/ + - run: + name: Check client translations in sync + command: | + docker exec restarters php artisan translations:export-client --check # Setup Discourse API (PostgreSQL must be ready; Discourse web app not required) - run: @@ -141,6 +183,17 @@ jobs: # Configure Discourse settings (only if Discourse web app is up) docker exec restarters php artisan discourse:setting personal_message_enabled_groups 10 || echo "Warning: discourse:setting failed - Discourse may not be fully started" + # Wait for the Discourse web app itself (non-fatal). Skipping npm + # made phpunit start ~12 min earlier, and Discourse-integration + # tests crawl against a cold Discourse (5s timeout + retries per + # call) — give it the warm-up window it used to get by accident. + echo "Waiting for Discourse web app (up to 10 min, non-fatal)..." + for i in $(seq 1 120); do + curl -f -s http://localhost:8003 >/dev/null 2>&1 && echo "✓ Discourse ready after $((i*5))s" && break + [ $((i % 12)) -eq 0 ] && echo " Still waiting for Discourse... ($((i*5))s)" + sleep 5 + done + # Run PHPUnit tests - run: name: Run PHPUnit tests @@ -151,49 +204,118 @@ jobs: docker cp restarters:/tmp/phpunit-results.xml /tmp/test-results/phpunit/results.xml no_output_timeout: 45m - # Run Jest tests + # Store artifacts + - store_artifacts: + path: /tmp/test-results + destination: playwright-test-results + + - store_test_results: + path: /tmp/test-results + + # Nuxt client: lint + unit tests + production build. Fast feedback, no + # docker-compose stack needed. + build-client: + docker: + - image: cimg/node:22.16 + resource_class: medium + steps: + - checkout + - restore_cache: + keys: + - client-npm-v1-{{ checksum "client/package-lock.json" }} + - client-npm-v1- + - run: + name: Install client dependencies + command: cd client && npm ci + - save_cache: + key: client-npm-v1-{{ checksum "client/package-lock.json" }} + paths: + - ~/.npm + - run: + name: Lint + command: cd client && npm run lint - run: - name: Run Jest tests + name: Vitest command: | - # Run Jest tests using Task for consistency with local development - task docker:test:jest - # Copy test results to host if they exist - docker cp restarters:/tmp/test-results/junit.xml /tmp/test-results/jest/junit.xml || echo "Jest results not found, skipping" + mkdir -p /tmp/test-results/vitest + cd client && npx vitest run --reporter=default --reporter=junit --outputFile=/tmp/test-results/vitest/results.xml + - run: + name: Nuxt build + command: cd client && npx nuxi build + - store_test_results: + path: /tmp/test-results + - store_artifacts: + path: /tmp/test-results + destination: client-test-results - # Run main Playwright tests (excluding autocomplete) + # Playwright against the real two-container stack (Laravel API + Nuxt client). + # Separate machine job — the monolithic `build` job's time budget is already + # tight, so client e2e gets its own timeout envelope. + e2e-client: + machine: + image: ubuntu-2204:current + resource_class: large + environment: + - TZ: "UTC" + steps: + - checkout + - install-task + - setup-env - run: - name: Run main Playwright tests + name: Start Docker services (core + client, production client build) command: | - # Run Playwright tests using Task for consistency with local development - task docker:test:playwright + export CIRCLECI=true + export COMPOSE_BAKE=true + export NUXT_DEV_MODE=false + task docker:up-core no_output_timeout: 10m - - # Copy test results and artifacts + # Probe an API route, not the default "/": post-cutover Laravel no longer + # serves "/" (the Nuxt/Nitro origin does), so "/" now returns 404 and the + # curl -f readiness check would never pass. /api/v2/session is a live API + # route (same probe the phpunit build job uses). + - wait-for-core: + probe: "http://localhost:8001/api/v2/session" + - run: + name: Wait for Nuxt client + command: | + echo "Waiting for the client container (npm ci + nuxi build on first boot)..." + for i in $(seq 1 120); do + curl -f -s http://localhost:3000 >/dev/null 2>&1 && echo "✓ Client ready after $((i*5))s" && exit 0 + [ $((i % 12)) -eq 0 ] && echo " Still waiting... ($((i*5))s elapsed)" + sleep 5 + done + echo "❌ Client not ready after 10 minutes — container logs:" + docker logs restarters_client --tail=100 2>&1 || true + exit 1 + no_output_timeout: 12m + - run: + name: Setup application + command: | + docker exec restarters_db mysql -u root -ps3cr3t -e "GRANT SELECT ON mysql.time_zone_name TO 'restarters'@'%';" + docker exec restarters_db mysql -u root -ps3cr3t -e "SET GLOBAL log_bin_trust_function_creators = 1;" + docker exec restarters_db mysql -u root -ps3cr3t -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));" + docker exec restarters php artisan l5-swagger:generate + - run: + name: Run client Playwright tests + command: | + task docker:test:playwright:client + no_output_timeout: 15m - run: name: Copy Playwright artifacts command: | - # Create test results directory on host - mkdir -p /tmp/test-results/playwright - mkdir -p /tmp/test-results/logs - - # List what's available in the playwright container - docker exec restarters_playwright bash -c "ls -la /tmp/test-results/* || echo 'No playwright directories found'" - - # Copy test results and artifacts from playwright container to host - docker cp restarters_playwright:/tmp/test-results/. /tmp/test-results/playwright/ || echo "Playwright HTML report not found" - - # Copy Vite log from restarters container for debugging - docker cp restarters:/tmp/vite.log /tmp/test-results/logs/vite.log || echo "Vite log not found" + mkdir -p /tmp/test-results/playwright-client + docker cp restarters_playwright:/tmp/test-results/. /tmp/test-results/playwright-client/ || echo "No playwright results found" when: always - - # Store artifacts - store_artifacts: path: /tmp/test-results - destination: playwright-test-results - + destination: client-e2e-results - store_test_results: path: /tmp/test-results + # develop is deployed as a preview, exactly like a PR: same fly.preview.toml, + # same preview-startup.sh, same disposable database restored from the latest + # hourly production backup, same suspend-on-idle teardown. The only + # develop-specific things left are the app name and the custom domain. deploy-fly-dev: docker: - image: cimg/base:current @@ -205,11 +327,53 @@ jobs: curl -L https://fly.io/install.sh | sh || true echo 'export PATH=/home/circleci/.fly/bin:$PATH' >> $BASH_ENV - run: - name: Deploy to restarters-dev + name: Deploy develop preview to restarters-dev command: | export FLY_API_TOKEN="FlyV1 $FLY_API_TOKEN" - flyctl deploy --config fly.dev.toml --remote-only + # Keep this substitution list in step with the one in + # .github/workflows/pr-preview.yml. + sed -e "s|__APP_NAME__|restarters-dev|g" \ + -e "s|__APP_TITLE__|Restarters develop|g" \ + -e "s|__BASE_URL__|https://restarters-dev.fly.dev|g" \ + -e "s|__SENTRY_ENV__|dev|g" \ + fly.preview.toml > fly.develop.generated.toml + if grep -q '__[A-Z_]*__' fly.develop.generated.toml; then + echo "Unsubstituted placeholder left in the generated config:" + grep -n '__[A-Z_]*__' fly.develop.generated.toml + exit 1 + fi + flyctl deploy --config fly.develop.generated.toml \ + --app restarters-dev --remote-only --ha=false + # --ha=false has a history of not reliably keeping one machine. + flyctl scale count 1 --app restarters-dev --yes no_output_timeout: 15m + - run: + name: Wait for restore and migrations + command: | + # Cold boot restores ~98MB of backup then migrates; until that + # finishes visitors get the warming page. Fail the build if the + # branch's migrations do not apply to real production data. + # preview-startup.sh writes the status with printf as one flat + # line, so sed reads it without needing jq on the image. + field() { echo "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p"; } + PHASE="" + for i in $(seq 1 80); do + BODY=$(curl -fsS --max-time 10 https://restarters-dev.fly.dev/_preview_status 2>/dev/null) || BODY="" + PHASE=$(field "$BODY" phase) + echo "poll $i: phase='${PHASE:-n/a}'" + case "$PHASE" in + ready|failed) break ;; + esac + sleep 15 + done + if [ "$PHASE" != "ready" ]; then + echo "restarters-dev.fly.dev did not become ready (phase='${PHASE:-unknown}')." + echo "detail: $(field "$BODY" detail)" + echo "Check: flyctl logs --app restarters-dev" + exit 1 + fi + echo "restarters-dev.fly.dev is ready." + no_output_timeout: 25m deploy-fly-prod: docker: @@ -235,9 +399,19 @@ workflows: filters: branches: ignore: production + - build-client: + filters: + branches: + ignore: production + - e2e-client: + filters: + branches: + ignore: production - deploy-fly-dev: requires: - build + - build-client + - e2e-client filters: branches: only: develop diff --git a/.dockerignore b/.dockerignore index efeaacc58d..19defa95bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ -node_modules +# Docker only applies a bare directory name at the top level, so `node_modules` +# alone never excluded client/node_modules (~466MB) — it was uploaded to the +# builder on every deploy, CI included. Use ** for anything nested. +**/node_modules vendor .git .env* @@ -14,4 +17,25 @@ tests/Browser/screenshots tests/Browser/console *.log .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Local-only clutter. These never exist in a CI checkout, but a manual +# `flyctl deploy` from a working tree uploads them: together they took the +# build context to 8.1GB / 358k files and made the deploy time out. +.worktrees/ +.claude/ +.idea/ +.vscode/ + +# Test/report output. All untracked, so a CI checkout never has them, but a +# local deploy would ship ~253MB of screenshots and reports. +playwright-report/ +parity-shots/ +test-results/ +/uploads/ + +# Root-level database dumps (e.g. restarters-anonymised.sql, ~662MB). +# Anchored to the root on purpose: database/data_updates/*.sql is tracked and +# must stay in the image. +/*.sql +/*.sql.gz diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index f715ccbba0..a99052f165 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -3,7 +3,7 @@ # Deploy: label a PR with 'preview' (or re-push while labeled, or run this # workflow manually with a PR number). Every deploy waits for # approval via the 'preview' GitHub Environment — approvers should -# glance at the PR's diff to .github/workflows/, fly.pr.toml, +# glance at the PR's diff to .github/workflows/, fly.preview.toml, # Dockerfile.fly and docker/*.sh before approving, since the deploy # runs the PR's own code. # Cleanup: a 6-hourly sweep destroys apps whose PR is closed or no longer @@ -116,7 +116,7 @@ jobs: - name: Overlay preview infra files from develop if still missing run: | git fetch origin develop --depth=1 - for f in fly.pr.toml docker/preview-startup.sh; do + for f in fly.preview.toml docker/preview-startup.sh; do if [ ! -f "$f" ]; then echo "Branch lacks $f - taking it from develop" git show origin/develop:"$f" > "$f" @@ -153,7 +153,7 @@ jobs: else flyctl apps create "$APP" --org "$FLY_ORG" # Shared read-only credentials (Tigris RO, Drive RO, gate password...) - # Strip comments/blank lines so the fly.pr-secrets.example.env + # Strip comments/blank lines so the fly.preview-secrets.example.env # template can be filled in and pasted as-is. echo "$FLY_PREVIEW_SECRETS" | grep -vE '^\s*(#|$)' \ | flyctl secrets import --app "$APP" --stage @@ -171,8 +171,14 @@ jobs: PR="${{ steps.pr.outputs.number }}" # Shown on the cookie-gate login page so testers know which PR this is. flyctl secrets set PREVIEW_PR_TITLE="$PR_TITLE" --app "$APP" --stage - sed "s/__PR_NUMBER__/$PR/g" fly.pr.toml > fly.pr.generated.toml - flyctl deploy --config fly.pr.generated.toml --app "$APP" \ + # fly.preview.toml is shared with the develop deploy in + # .circleci/config.yml; keep the two substitution lists in step. + sed -e "s|__APP_NAME__|$APP|g" \ + -e "s|__APP_TITLE__|Restarters PR #$PR|g" \ + -e "s|__BASE_URL__|https://$APP.fly.dev|g" \ + -e "s|__SENTRY_ENV__|preview|g" \ + fly.preview.toml > fly.preview.generated.toml + flyctl deploy --config fly.preview.generated.toml --app "$APP" \ --remote-only --ha=false # --ha=false has a history of not reliably keeping one machine. flyctl scale count 1 --app "$APP" --yes diff --git a/.github/workflows/stop-dev-nightly.yml b/.github/workflows/stop-dev-nightly.yml deleted file mode 100644 index e7e3d0d68c..0000000000 --- a/.github/workflows/stop-dev-nightly.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Stop restarters-dev nightly - -# Stops the restarters-dev Fly machine(s) every night to avoid paying for the -# dev/staging box while it sits idle overnight. -# -# How it stays cheap but stays usable: -# * fly.dev.toml has auto_stop_machines = "off", so Fly never auto-stops the -# box during the day AND never auto-restarts a machine we stop here -# (min_machines_running is only enforced when auto_stop is enabled, so it is -# inert for us — verified: a manual `fly machine stop` stays stopped). -# * fly.dev.toml has auto_start_machines = true, so the machine boots again -# automatically the first time anyone makes a request in the morning. -# -# Net effect: off overnight (no CPU billing), back on demand. A stopped machine -# still keeps its volume, so no data is lost. -# -# NOTE ON TIME: GitHub Actions cron is UTC and does NOT follow BST/DST. -# 0 2 * * * == 02:00 UTC == 03:00 London in summer (BST) / 02:00 in winter. -# Adjust the cron if you want exactly 02:00 London year-round. - -on: - schedule: - - cron: '0 2 * * *' # 02:00 UTC daily - workflow_dispatch: {} # allow manual runs from the Actions tab - -jobs: - stop-dev: - runs-on: ubuntu-latest - steps: - # Pinned to a full commit SHA (supply-chain hardening; SonarCloud S7637). - # ed8efb3 = superfly/flyctl-actions master as of 2026-07-03. - - uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 - - name: Stop all restarters-dev machines - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - run: | - set -euo pipefail - ids=$(flyctl machine list -a restarters-dev --json | jq -r '.[].id') - if [ -z "$ids" ]; then - echo "No machines found for restarters-dev — nothing to stop." - exit 0 - fi - for id in $ids; do - echo "Stopping machine $id" - flyctl machine stop "$id" -a restarters-dev - done - echo "Done. Machines will auto-start on the next request." diff --git a/.gitignore b/.gitignore index e2459d1265..026fcba8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,9 +32,29 @@ restarters-anonymised.sql /.claude/settings.local.json /public/build/ /storage/api-docs/api-docs.json +/storage/app/tus +/storage/app/tus-cache /.phpunit.result.cache /playwright-report/ /events.csv /public/repair-data*.csv /uploads/*.jpg .mcp.json + +# Local ralph session log (not for commit) +.claude-session.md +# PHPUnit coverage artifact +/tests/clover.xml + +# Visual parity capture output (task parity:capture) - screenshots, never committed +parity-shots/ + +# Canvas share-image backgrounds (224MB, 267 files). These live in the Laravel +# app at public/images/stats and must NOT be duplicated into the client bundle. +# The client reaches them through an /api/* endpoint so CORS applies and the +# canvas stays untainted - see the stats-share composable. +client/public/images/stats/ +parity-fixtures.json + +# Generated from fly.preview.toml by CI (and by hand for manual deploys) +fly.*.generated.toml diff --git a/.php_cs.cache b/.php_cs.cache new file mode 100644 index 0000000000..49927cc2fd --- /dev/null +++ b/.php_cs.cache @@ -0,0 +1 @@ +{"php":"8.4.21","version":"2.19.1","indent":" ","lineEnding":"\n","rules":{"array_syntax":{"syntax":"short"},"binary_operator_spaces":{"default":"single_space","operators":{"=>":null}},"blank_line_after_namespace":true,"blank_line_after_opening_tag":true,"blank_line_before_statement":{"statements":["return"]},"braces":true,"cast_spaces":true,"class_attributes_separation":{"elements":["method"]},"class_definition":true,"concat_space":{"spacing":"none"},"declare_equal_normalize":true,"elseif":true,"encoding":true,"full_opening_tag":true,"fully_qualified_strict_types":true,"function_declaration":true,"function_typehint_space":true,"heredoc_to_nowdoc":true,"include":true,"increment_style":{"style":"post"},"indentation_type":true,"linebreak_after_opening_tag":true,"line_ending":true,"lowercase_cast":true,"lowercase_constants":true,"lowercase_keywords":true,"lowercase_static_reference":true,"magic_method_casing":true,"magic_constant_casing":true,"method_argument_space":true,"native_function_casing":true,"no_alias_functions":true,"no_extra_blank_lines":{"tokens":["extra","throw","use","use_trait"]},"no_blank_lines_after_class_opening":true,"no_blank_lines_after_phpdoc":true,"no_closing_tag":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_leading_import_slash":true,"no_leading_namespace_whitespace":true,"no_mixed_echo_print":{"use":"echo"},"no_multiline_whitespace_around_double_arrow":true,"multiline_whitespace_before_semicolons":{"strategy":"no_multi_line"},"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_spaces_after_function_name":true,"no_spaces_around_offset":true,"no_spaces_inside_parenthesis":true,"no_trailing_comma_in_list_call":true,"no_trailing_comma_in_singleline_array":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"no_unneeded_control_parentheses":true,"no_unreachable_default_argument_value":true,"no_useless_return":true,"no_whitespace_before_comma_in_array":true,"no_whitespace_in_blank_line":true,"normalize_index_brace":true,"not_operator_with_successor_space":true,"object_operator_without_whitespace":true,"ordered_imports":{"sortAlgorithm":"alpha"},"phpdoc_indent":true,"phpdoc_inline_tag":true,"phpdoc_no_access":true,"phpdoc_no_package":true,"phpdoc_no_useless_inheritdoc":true,"phpdoc_scalar":true,"phpdoc_single_line_var_spacing":true,"phpdoc_summary":true,"phpdoc_to_comment":true,"phpdoc_trim":true,"phpdoc_types":true,"phpdoc_var_without_name":true,"psr4":true,"self_accessor":true,"short_scalar_cast":true,"single_blank_line_at_eof":true,"single_blank_line_before_namespace":true,"single_class_element_per_statement":true,"single_import_per_statement":true,"single_line_after_imports":true,"single_line_comment_style":{"comment_types":["hash"]},"single_quote":true,"space_after_semicolon":true,"standardize_not_equals":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"ternary_operator_spaces":true,"trailing_comma_in_multiline_array":true,"trim_array_spaces":true,"unary_operator_spaces":true,"visibility_required":{"elements":["method","property"]},"whitespace_after_comma_in_array":true},"hashes":{"app\/Http\/Controllers\/API\/StatsShareImageController.php":653998297,"tests\/Feature\/Devices\/APIv2StatsShareImageTest.php":3643096653,"routes\/api.php":180907154}} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 11604aabab..b6d03de560 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,9 +25,9 @@ task docker:run:artisan -- migrate # Run artisan command task docker:shell # Open shell in container # Testing -task docker:test:phpunit # Run PHP tests -task docker:test:jest # Run JS tests -task docker:test:playwright # Run e2e tests +task docker:test:phpunit # Run PHP tests +task docker:test:vitest # Run client (Nuxt) unit tests +task docker:test:playwright:client # Run client e2e tests # Vite dev server (HMR) task docker:vite:start # Start Vite in background @@ -74,11 +74,9 @@ task docker:shell # Open shell in container task docker:run:artisan -- migrate # Run artisan commands task docker:run:bash -- "command" # Run bash commands -# Run Vite for HMR (hot module replacement) -task docker:run:bash -- "npm run dev" - # The application will be available at: -# - Restarters: http://localhost:8001 (Admin: jane@bloggs.net / passw0rd) +# - Frontend (Nuxt SPA, restarters_client): http://localhost:8004 (Admin: jane@bloggs.net / passw0rd) +# - Laravel API: http://localhost:8001 # - phpMyAdmin: http://localhost:8002 (Host: restarters_db, User: root, Pass: s3cr3t) # - Mailhog: http://localhost:8025 # - Discourse: http://localhost:8003 @@ -89,11 +87,13 @@ task docker:run:bash -- "npm run dev" # Install PHP dependencies composer install -# Install and build frontend assets -npm install -npm run dev # Development build -npm run watch # Watch for changes -npm run production # Production build +# Frontend (Nuxt SPA) — the restarters_client container runs the dev server; +# for local tooling outside Docker: +cd client && npm install && npm run dev + +# Widget/wiki assets still built by Laravel-side Vite (rarely needed): +npm install --legacy-peer-deps +npm run build # Laravel commands php artisan migrate # Run database migrations @@ -121,11 +121,11 @@ export DB_TEST_HOST=restarters_db # Run specific test method ./vendor/bin/phpunit --filter testMethodName -# Run JavaScript tests -npm run jest +# Run client unit tests (from client/) +cd client && npx vitest run -# Run Playwright end-to-end tests -npm test +# Run client Playwright end-to-end tests +task docker:test:playwright:client ``` ### Code Quality @@ -168,11 +168,14 @@ npm test - JSON columns for flexible network_data storage ### Frontend Stack -- **Vue 2** components for interactive features -- **Laravel Mix** for asset compilation -- **Bootstrap 4** for styling -- **SCSS** for styles in `resources/sass/` -- Multiple build targets: main app, global styles, wiki styles +- **Nuxt 4 SPA in `client/`** — Vue 3, Pinia, bootstrap-vue-next (Bootstrap 5), + @nuxtjs/i18n, Vitest; talks to Laravel exclusively via `/api/v2` +- Laravel serves no user-facing pages (see `tests/Feature/ApiOnlyRouteSurfaceTest.php` + for the pinned web-route surface): only the embeddable stats widgets and the + MediaWiki skin assets are still built here (Vite, `resources/global` + + `resources/wiki`, jQuery/Bootstrap 4 only) +- Client translations are exported from `lang/*.php` via + `php artisan translations:export-client` (checked in CI) ### Permission System - Role-based permissions via custom `app/Role.php` and `app/Permissions.php` diff --git a/Dockerfile.fly b/Dockerfile.fly index 17720324d9..857e9cb1c1 100644 --- a/Dockerfile.fly +++ b/Dockerfile.fly @@ -17,8 +17,9 @@ RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable # Install composer COPY --from=composer/composer:2-bin /composer /usr/bin/composer -# Install Node 18 -RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ +# Install Node 22 (Nuxt 4 / nuxi require Node >=22; matches client/Dockerfile +# node:22 and CI cimg/node:22.16. Vite 4 root build is Node-22-compatible too.) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs \ && apt-get clean && rm -rf /var/lib/apt/lists/* @@ -53,6 +54,19 @@ RUN php artisan package:discover --ansi || true RUN php artisan lang:js --no-lib resources/js/translations.js 2>/dev/null || true RUN npm run production && rm -f public/hot +# Build the Nuxt SPA (the separate app in client/). `nuxt build` emits a +# standalone Nitro Node server at client/.output that bundles its own runtime +# deps, so production serves the SPA origin via +# `node client/.output/server/index.mjs` (see docker/supervisord-fly.conf). +# apiBase is runtime-overridable (NUXT_PUBLIC_API_BASE), so no API URL is baked +# in here. Drop client/node_modules afterwards — .output is self-contained, and +# this keeps the final-stage `COPY /build` from dragging the dev install in. +WORKDIR /build/client +RUN npm ci --legacy-peer-deps +RUN npm run build +RUN rm -rf node_modules +WORKDIR /build + # Generate swagger docs (non-fatal if it fails) RUN php artisan l5-swagger:generate 2>/dev/null || true @@ -77,6 +91,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ util-linux \ default-mysql-client \ rclone \ + curl ca-certificates gnupg \ libpng-dev libjpeg62-turbo-dev libfreetype6-dev libzip-dev libicu-dev libxml2-dev \ && if [ "$STARTUP_SCRIPT" != "startup.sh" ]; then apt-get install -y --no-install-recommends default-mysql-server; fi \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ @@ -86,6 +101,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install xmlrpc RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable xmlrpc +# Node 22 (runtime only) — runs the Nuxt Nitro server that serves the SPA origin +# (client/.output, started by supervisord). The .output targets Node >=22, same +# as the builder. This image only needs the node binary to run it. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + # Configure PHP-FPM to use unix socket. # The base image ships docker.conf (formerly zz-docker.conf) with: # listen = 9000 — overrides www.conf (last-file-wins in glob) diff --git a/Taskfile.yml b/Taskfile.yml index 772e1a3240..e8aeddc78c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -171,6 +171,21 @@ tasks: cmds: - docker exec -it restarters php artisan "{{ .CLI_ARGS }}" + docker:swagger:generate: + desc: Regenerate the OpenAPI (Swagger) spec from the @OA annotations. + summary: | + Regenerate storage/api-docs/api-docs.json from the @OA annotations under app/. + The spec is a gitignored build artifact; CI regenerates it before the PHPUnit + and Playwright jobs. Run this after editing any @OA annotation. + cmds: + - docker exec restarters php artisan l5-swagger:generate + + docker:swagger:lint: + desc: Regenerate the OpenAPI spec and check document-level integrity (dangling refs, security schemes, duplicate operationIds). + cmds: + - docker exec restarters php artisan l5-swagger:generate + - docker exec restarters php tools/openapi-lint.php + docker:vite: desc: Start Vite dev server with HMR in the container. summary: | @@ -219,75 +234,152 @@ tasks: cmds: - mkdir -p /tmp/test-results/phpunit - | - if [ -z "$CIRCLECI" ]; then - echo "Resetting test database and running migrations..." - docker exec restarters bash -c "php artisan migrate:fresh --seed --database=mysql_testing --force" + # Always reset the test DB here (CI included). CI used to skip this, + # relying on the container-startup migrate:fresh — but that runs with + # no `set -e`, so a startup migration failure silently left the CI + # test DB missing the newest tables (seen 2026-07-16: sso_tickets + # absent while every earlier table existed). Explicit reset right + # before phpunit is deterministic and matches local behaviour. + echo "Resetting test database and running migrations..." + docker exec restarters bash -c "php artisan migrate:fresh --seed --database=mysql_testing --force" + - | + # Coverage only where it's consumed: Coveralls tracks develop. The + # CircleCI plan caps jobs at 60 minutes and the develop build already + # takes ~54 — coverage-instrumented runs of the grown suite on feature + # branches blew the cap (three kills at exactly 60.0 min, mid-test). + # NOTE (pre-merge cliff, tracked in plan G1): once this branch merges, + # develop's suite grows too — the build job needs a split or a plan + # bump before then. + if [ -n "$COVERALLS_REPO_TOKEN" ] && { [ -z "$CIRCLE_BRANCH" ] || [ "$CIRCLE_BRANCH" = "develop" ]; }; then + docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "export XDEBUG_MODE=coverage; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --coverage-clover tests/clover.xml --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}" else - echo "Running on CircleCI - skipping database reset (handled by CI setup)" + docker exec restarters bash -c "export XDEBUG_MODE=off; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}" fi - - docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "export XDEBUG_MODE=coverage; ./vendor/bin/phpunit -d memory_limit=1024M --bootstrap vendor/autoload.php --coverage-clover tests/clover.xml --log-junit /tmp/phpunit-results.xml --configuration ./phpunit.xml --teamcity {{ .CLI_ARGS }}" - | - if [ ! -z "$COVERALLS_REPO_TOKEN" ]; then + # Mirror the generation gate above: no clover.xml is produced on + # non-develop CI branches, so there is nothing to upload there. + if [ -n "$COVERALLS_REPO_TOKEN" ] && { [ -z "$CIRCLE_BRANCH" ] || [ "$CIRCLE_BRANCH" = "develop" ]; }; then echo "Uploading coverage to Coveralls..." docker exec -e COVERALLS_REPO_TOKEN="$COVERALLS_REPO_TOKEN" restarters bash -c "./upload-coverage.sh tests/clover.xml" else echo "COVERALLS_REPO_TOKEN not set, skipping coverage upload" fi - docker:test:jest: - desc: Run Jest tests in the core application container. - summary: | - Run Jest tests with JUnit output for CI integration. - This ensures consistency between local development and CI environments. + docker:client:logs: + desc: Tail the Nuxt client container logs. + cmds: + - docker logs --follow restarters_client + docker:client:restart: + desc: Restart the Nuxt client container (e.g. after changing client env vars). cmds: - - mkdir -p /tmp/test-results/jest - - docker exec restarters bash -c "npm i jest-junit; JEST_JUNIT_OUTPUT_DIR=/tmp/test-results npm run jest -- --testResultsProcessor=jest-junit" + - "{{.DOCKER_CMD}} --profile core restart restarters_client" - docker:test:playwright: - desc: Run Playwright tests in dedicated playwright container. + docker:test:vitest: + desc: Run the client Vitest suite in the client container. summary: | - Run Playwright end-to-end tests using a dedicated playwright container. - This ensures proper isolation and reliable network access to the application. - - The command includes: - - Environment variables for test configuration - - HTML report generation - - Proper test isolation + Runs the Nuxt client's unit tests with JUnit output for CI integration. + cmds: + - mkdir -p /tmp/test-results/vitest + - docker exec restarters_client npx vitest run --reporter=default --reporter=junit --outputFile=test-results/vitest.xml + - cp client/test-results/vitest.xml /tmp/test-results/vitest/results.xml + playwright:seed-data: + desc: Seed the fixed users/network/group the Playwright suites depend on. + internal: true cmds: - - mkdir -p /tmp/test-results/playwright - docker exec restarters bash -c "php artisan cache:clear" - docker exec restarters bash -c "echo \"DB::statement('SET foreign_key_checks=0'); App\\\\Device::truncate(); DB::statement('SET foreign_key_checks=1');\" | php artisan tinker" || true - docker exec restarters bash -c "echo \"\\\$u=App\\\\User::firstOrCreate(['email'=>'jane@bloggs.net'], ['name'=>'Jane Bloggs','password'=>Hash::make('passw0rd'),'consent_past_data'=>'2021-01-01','consent_future_data'=>'2021-01-01','consent_gdpr'=>'2021-01-01']);\\\$u->role=2;\\\$u->save();\" | php artisan tinker" || true - | # Create test data for group tags tests (NC user, host user, network, group) docker exec restarters php artisan tinker --execute='$nc = App\User::firstOrCreate(["email"=>"nc@test.net"], ["name"=>"NC User","password"=>Hash::make("passw0rd"),"consent_past_data"=>"2021-01-01","consent_future_data"=>"2021-01-01","consent_gdpr"=>"2021-01-01"]); $nc->role=6; $nc->save(); $host = App\User::firstOrCreate(["email"=>"host@test.net"], ["name"=>"Host User","password"=>Hash::make("passw0rd"),"consent_past_data"=>"2021-01-01","consent_future_data"=>"2021-01-01","consent_gdpr"=>"2021-01-01"]); $host->role=3; $host->save(); $network = App\Network::where("name","Test London")->first(); if (!$network) { $network = new App\Network(); $network->name="Test London"; $network->shortname="testlondon"; $network->description="Test network"; $network->default_language="en"; $network->save(); } DB::table("user_network")->insertOrIgnore(["user_id"=>$nc->id,"network_id"=>$network->id]); $group = App\Group::firstOrCreate(["name"=>"Tag Test Group"], ["website"=>"https://test.example.com","location"=>"London","area"=>"London","postcode"=>"SW1A 1AA","latitude"=>51.5,"longitude"=>-0.1,"free_text"=>"Test group","approved"=>true]); DB::table("group_network")->insertOrIgnore(["group_id"=>$group->idgroups,"network_id"=>$network->id]); DB::table("users_groups")->insertOrIgnore(["user"=>$host->id,"group"=>$group->idgroups,"role"=>3,"status"=>1]); echo "Group tags test data: NC=".$nc->id." Host=".$host->id." Network=".$network->id." Group=".$group->idgroups;' || true - - docker exec restarters bash -c "sed -i 's/.throttle:api.,//g' /var/www/app/Http/Kernel.php" - docker exec restarters bash -c "sed -i 's/APP_DEBUG=.*$/APP_DEBUG=false/g' /var/www/.env" - docker exec restarters bash -c "sed -i 's/QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/g' /var/www/.env" - docker exec restarters bash -c "sed -i 's/MAIL_MAILER=.*/MAIL_MAILER=log/g' /var/www/.env" + + docker:test:playwright:client: + desc: Run the client Playwright suite against the Nuxt container. + summary: | + End-to-end tests for the Nuxt SPA (client/e2e, playwright.client.config.js). + The browser loads the SPA from the restarters_client container and the SPA + calls the Laravel API via www.example.com:8001 (host-gateway). + cmds: + - mkdir -p /tmp/test-results/playwright + - task: playwright:seed-data - | - # Stop Vite dev server and build assets for production - echo "Stopping Vite dev server and building assets..." - docker exec restarters bash -c "ps aux | grep 'node.*vite' | grep -v grep | awk '{print \$2}' | xargs -r kill || true" - docker exec restarters bash -c "rm -f /var/www/public/hot && npm run build" - - | - # Ensure Playwright browsers are installed (handles version mismatches) - echo "Installing Playwright browsers..." - docker exec restarters_playwright bash -c "npx playwright install chromium" + # Relax the auth-endpoint rate limit for test bursts (named limiter + # reads AUTH_RATE_LIMIT; .env edits are the established pattern here). + docker exec restarters bash -c "grep -q '^AUTH_RATE_LIMIT' /var/www/.env && sed -i 's/^AUTH_RATE_LIMIT=.*/AUTH_RATE_LIMIT=1000/' /var/www/.env || echo 'AUTH_RATE_LIMIT=1000' >> /var/www/.env; php artisan config:clear" + - docker exec restarters_playwright bash -c "npx playwright install chromium" - | docker exec restarters_playwright bash -c " export PLAYWRIGHT_TEST=true - export PLAYWRIGHT_DEBUG=true - export PWTEST_SKIP_TEST_OUTPUT=0 - export DEBUG=playwright - export PLAYWRIGHT_BASE_URL=http://restarters_nginx + export PLAYWRIGHT_BASE_URL=http://restarters_client:3000 + export PLAYWRIGHT_API_URL=http://www.example.com:8001 export PW_TEST_HTML_REPORT_OPEN=never export FORCE_COLOR=1 - stdbuf -oL -eL npx playwright test --reporter=html + stdbuf -oL -eL npx playwright test --config=playwright.client.config.js --reporter=html {{ .CLI_ARGS }} " + parity:capture: + desc: Capture matched dev-vs-live screenshot pairs for visual parity diffing. + summary: | + Drives BOTH LOCAL DEV systems through the same page list at desktop + + mobile, writing matched pairs to + parity-shots//__{new,old}.png so they can be diffed + side by side. The systematic replacement for eyeballing pages one at a time. + + new = the Nuxt SPA under development (restarters_client:3000, :8004) + old = the legacy Blade app from origin/develop + (restarters_legacy_nginx, host :8005) + + Both run against the SAME seeded database with the same test login, so + every difference is a real difference rather than production-vs-test-data + noise - and because neither is production, non-read-only flows + (create/edit wizards) can be exercised safely. No production credentials + are involved anywhere. + + To stand up the legacy instance see + docs/nuxt-migration/findings/parity-audit.md. + + Uses its own config (playwright.parity.config.js, testDir client/parity) + so the CI suite never runs it. + cmds: + - mkdir -p parity-shots/desktop parity-shots/mobile + # Same preconditions as docker:test:playwright:client - without the seeded + # users the dev login has no account to log in as, and the auth rate limit + # rejects the repeated logins this harness performs. + - task: playwright:seed-data + # Adds what seed-data does not (an event, a default 'restarters' network + # for CheckForRepairNetwork) and publishes parity-fixtures.json, which + # capture.spec.js reads to build the detail-page URLs. Must run every + # time: migrate:fresh renumbers ids, and a stale id silently renders as + # a 404 on both systems, which then compares as a false match. + # NB `php artisan tinker ` exits 1 here even when the script runs + # perfectly (psysh quirk - a trivial echo script exits 1 too), so its + # exit code carries no information. Assert on the script's own end + # marker instead. `< /dev/null` is required or tinker waits on stdin + # forever. + - | + docker exec restarters bash -c "php artisan tinker parity-fixtures.php < /dev/null" 2>&1 \ + | grep -q 'FIXTURES .* END' \ + || { echo 'parity-fixtures.php did not complete - detail-page ids unavailable'; exit 1; } + - | + docker exec restarters bash -c "grep -q '^AUTH_RATE_LIMIT' /var/www/.env && sed -i 's/^AUTH_RATE_LIMIT=.*/AUTH_RATE_LIMIT=1000/' /var/www/.env || echo 'AUTH_RATE_LIMIT=1000' >> /var/www/.env; php artisan config:clear" + - docker exec restarters_playwright bash -c "npx playwright install chromium" + - | + docker exec \ + -e PARITY_NEW_BASE="${PARITY_NEW_BASE:-http://restarters_client:3000}" \ + -e PARITY_OLD_BASE="${PARITY_OLD_BASE:-http://restarters_legacy_nginx}" \ + -e PARITY_EMAIL="${PARITY_EMAIL:-jane@bloggs.net}" \ + -e PARITY_PASSWORD="${PARITY_PASSWORD:-passw0rd}" \ + restarters_playwright bash -c " + export PW_TEST_HTML_REPORT_OPEN=never + export FORCE_COLOR=1 + stdbuf -oL -eL npx playwright test --config=playwright.parity.config.js --reporter=list {{ .CLI_ARGS }} + " + docker:wait-for-services-*: desc: Wait for Docker services to be ready and responding for a given profile (Usage - task docker:wait-for-services-[core|debug|discourse|all]) summary: | diff --git a/app/Auditing/SanitisedUrlResolver.php b/app/Auditing/SanitisedUrlResolver.php new file mode 100644 index 0000000000..c4d9879010 --- /dev/null +++ b/app/Auditing/SanitisedUrlResolver.php @@ -0,0 +1,37 @@ +checkKeys($keys, $group); } @@ -71,6 +75,10 @@ private function checkKeys($keys, $prefix): int { $count = 0; + if (! is_array($keys)) { + return 0; + } + foreach ($keys as $key => $value) { $fullKey = "$prefix.$key"; @@ -86,6 +94,9 @@ private function checkKeys($keys, $prefix): int // will want to remove it and it doesn't matter if it is not translated properly. if (strpos($fullKey, 'groups.tag-') === 0) { // This is valid - it's used in a constructed way. + } else if (preg_match('/^admin\.reliability-\d$/', $fullKey)) { + // Constructed in the SPA: t(`admin.reliability-${n}`) + // (client/app/pages/category.vue), so grep can't see it. } else if (!$this->usedInCode($fullKey)) { error_log("ERROR: translation key $fullKey not used in code so far as we can tell"); $count++; @@ -113,25 +124,80 @@ private function checkKeys($keys, $prefix): int } private function usedInCode($key) { + return isset($this->usedKeys()[$key]); + } + + private $usedKeysCache = null; + + /** + * One grep per scanned tree instead of one per key: with ~2000 keys the + * per-key approach spawned ~8000 grep processes and took ~20 minutes + * inside the phpunit suite. A single grep -rhoF -f pass per + * tree collects every key that appears anywhere in a few seconds. + * resources/js died at the Nuxt-migration cutover; the SPA under + * client/ is now the main consumer. + */ + private function usedKeys(): array { + if ($this->usedKeysCache !== null) { + return $this->usedKeysCache; + } + + $keys = []; + foreach (scandir(base_path('lang/en')) as $file) { + if (strpos($file, '.php') === false) { + continue; + } + $group = substr($file, 0, strpos($file, '.')); + $this->collectKeys(\Lang::get($group, [], 'en'), $group, $keys); + } + + // One in-memory haystack per scanned tree, then strpos per key — + // same substring semantics as the old per-key grep, without the + // process spawns. + $haystack = ''; foreach ([ - 'resources/views/', // Blade templates - 'resources/js/components/', // Vue templates - 'resources/js/mixins/', // Vue mixins (rare) - 'app/', // Models (rare) - 'app/Notifications/', // Email notifications - 'app/Http/Controllers/', // Controllers (rarely) - 'app/Http/Middleware/', // Middleware(rarely) - 'app/Services/', // Services(rarely) - 'app/' // Models rarely) + 'resources/views/', // retained widget/email Blade + 'client/app/', // Nuxt SPA (t('file.key') usage) + 'client/e2e/', // SPA e2e specs asserting strings + 'app/', // notifications, controllers, services ] as $loc) { - $cmd = 'grep -r "' . addslashes($key) . '" ' . $loc . ' > /dev/null'; - system($cmd, $rc); + if (!is_dir(base_path($loc))) { + continue; + } + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(base_path($loc), \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $fileInfo) { + if ($fileInfo->isFile()) { + $haystack .= file_get_contents($fileInfo->getPathname())."\n"; + } + } + } - if ($rc == 0) { - return true; + $found = []; + foreach ($keys as $key) { + if (strpos($haystack, $key) !== false) { + $found[$key] = true; } } + $this->usedKeysCache = $found; - return false; + return $found; + } + + private function collectKeys($keys, $prefix, array &$out): void { + // \Lang::get($group) returns the group name (a string) when a file + // does not resolve to a translation array (e.g. _json.php's DB data, + // or a group with no file). Nothing to collect in that case. + if (! is_array($keys)) { + return; + } + + foreach ($keys as $key => $value) { + $fullKey = "$prefix.$key"; + if (is_array($value)) { + $this->collectKeys($value, $fullKey, $out); + } else { + $out[] = $fullKey; + } + } } } diff --git a/app/Console/Commands/ExportClientTranslations.php b/app/Console/Commands/ExportClientTranslations.php new file mode 100644 index 0000000000..7ddc483a4a --- /dev/null +++ b/app/Console/Commands/ExportClientTranslations.php @@ -0,0 +1,154 @@ +.json). Laravel lang files remain the single + * source of truth; the client JSONs are generated and committed, with a CI + * check (--check) that they are in sync. + * + * Conversions (Laravel → vue-i18n message syntax): + * - :param → {param} + * - {1} x|[0,*] y → x | y (positional plural forms; Laravel's + * exact/range tags are stripped — for this corpus the positional selection + * matches Laravel's MessageSelector, which the client parity tests pin) + * - literal @ → {'@'} (vue-i18n linked-message escape) + * A parity test suite on the client side compiles every exported message and + * compares plural behaviour against Laravel semantics; anything this command + * cannot convert safely aborts the export (fail loudly, never silently drop). + */ +class ExportClientTranslations extends Command +{ + protected $signature = 'translations:export-client + {--check : Verify the committed client JSONs match a fresh export; exit 1 on drift}'; + + protected $description = 'Export lang/ PHP translations to vue-i18n JSON for the Nuxt client'; + + private const LOCALES = ['en', 'fr', 'fr-BE']; + + public function handle(): int + { + $outDir = base_path('client/i18n/locales'); + + foreach (self::LOCALES as $locale) { + $messages = $this->buildLocale($locale); + $json = json_encode($messages, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"; + $target = $outDir.'/'.$locale.'.json'; + + if ($this->option('check')) { + if (! File::exists($target) || File::get($target) !== $json) { + $this->error("$target is out of sync with lang/ — run: php artisan translations:export-client"); + + return self::FAILURE; + } + } else { + File::ensureDirectoryExists($outDir); + File::put($target, $json); + $this->info("Wrote $target"); + } + } + + if ($this->option('check')) { + $this->info('Client translations are in sync.'); + } + + return self::SUCCESS; + } + + private function buildLocale(string $locale): array + { + $messages = []; + + // lang//*.php → nested under the file basename (file.key). + foreach (File::glob(base_path("lang/$locale/*.php")) as $path) { + $group = basename($path, '.php'); + $strings = include $path; + + if (! is_array($strings)) { + $this->error("$path did not return an array"); + throw new \RuntimeException("Unexportable lang file: $path"); + } + + $messages[$group] = $this->convertArray($strings, "$locale/$group"); + } + + // lang/.json → top-level keys (device category/cluster names). + $jsonPath = base_path("lang/$locale.json"); + if (File::exists($jsonPath)) { + $extra = json_decode(File::get($jsonPath), true); + if (is_array($extra)) { + $messages = array_replace($this->convertArray($extra, "$locale.json"), $messages); + } + } + + ksort($messages); + + return $messages; + } + + private function convertArray(array $strings, string $context): array + { + $out = []; + + foreach ($strings as $key => $value) { + if (is_array($value)) { + $out[$key] = $this->convertArray($value, "$context.$key"); + } elseif (is_string($value)) { + $out[$key] = $this->convertString($value, "$context.$key"); + } else { + // Numeric/bool values appear in a few lang files; pass through. + $out[$key] = $value; + } + } + + return $out; + } + + private function convertString(string $value, string $context): string + { + // Plural forms: strip Laravel's exact/range tags ({1} x, [0,*] y), + // keeping segment order — vue-i18n selects positionally on the same + // 0/1/2+ boundaries for these corpora (pinned by client parity tests). + if (str_contains($value, '|')) { + $segments = explode('|', $value); + $stripped = array_map(function ($segment) { + return preg_replace('/^\s*(\{\d+\}|\[\d+,(?:\d+|\*)\])\s*/', '', $segment); + }, $segments); + + // A tagged segment list we cannot represent positionally + // (e.g. sparse exacts like {5}) must fail the export. + foreach ($segments as $i => $segment) { + if (preg_match('/^\s*\{(\d+)\}/', $segment, $m)) { + if ((int) $m[1] !== $i && ! in_array((int) $m[1], [0, 1], true)) { + throw new \RuntimeException( + "Unconvertible plural form in $context: '$value' — segment {$m[0]} is not positional" + ); + } + } + } + + // No spaces around the pipe: matches Laravel's own bare '|' format + // exactly, so the exported string is byte-for-byte what a plain + // `str_replace(':count', ..., $laravelString)` would have produced + // pre-conversion - one less variable when comparing exported vs. + // source copy (dashboard.md parity gap #1). + $value = implode('|', array_map('trim', $stripped)); + } + + // Laravel :param → vue-i18n {param}. The negative lookbehind keeps + // scheme-style colons intact (mailto:janet must not become {janet}) — + // genuine params are always preceded by a non-word character. + $value = preg_replace('/(?where('url', 'like', '%?%') + ->count(); + + if ($this->option('dry-run')) { + $this->info("Would scrub {$affected} audit URL(s)."); + + return self::SUCCESS; + } + + if ($affected === 0) { + $this->info('No audit URLs contain a query string.'); + + return self::SUCCESS; + } + + // SUBSTRING_INDEX keeps everything before the first '?'. Done in SQL + // rather than by loading models: the audits table is append-only and + // can be very large, and rewriting it row by row through Eloquent + // would also touch updated_at on records that are meant to be + // immutable. + DB::statement("UPDATE audits SET url = SUBSTRING_INDEX(url, '?', 1) WHERE url LIKE '%?%'"); + + $this->info("Scrubbed {$affected} audit URL(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index f47205a434..07b8f9e569 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,6 +2,9 @@ namespace App\Exceptions; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Validation\ValidationException; use Throwable; use Exception; @@ -28,13 +31,32 @@ class Handler extends ExceptionHandler */ public function render($request, Throwable $exception) { - if ($request->wantsJson()) { + // /api/v2 is an API-only surface (the Nuxt SPA + documented OpenAPI + // contract): always render errors as JSON there, even when the caller + // didn't send an Accept: application/json header, so the response shape + // matches the documented #/components/responses/* error schemas. + if ($request->wantsJson() || $request->is('api/v2/*')) { if ($exception instanceof ValidationException) { return response()->json( ['message' => $exception->getMessage(), 'errors' => $exception->errors()], 422); } + // AuthenticationException / AuthorizationException don't implement + // getStatusCode(), so without these the generic branch below rendered + // them as 500 instead of the correct 401/403 for JSON API requests. + if ($exception instanceof AuthenticationException) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + if ($exception instanceof AuthorizationException) { + return response()->json(['message' => 'Unauthorized.'], 403); + } + + if ($exception instanceof ModelNotFoundException) { + return response()->json(['message' => 'Resource not found.'], 404); + } + return response()->json( ['message' => $exception->getMessage()], method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500); diff --git a/app/Group.php b/app/Group.php index d61feb3f5c..c5edf9676c 100644 --- a/app/Group.php +++ b/app/Group.php @@ -110,7 +110,7 @@ public function group_tags(): BelongsToMany */ public function getFilteredTagsForUser() { - $user = auth()->user() ?? auth('api')->user(); + $user = auth()->user() ?? auth('sanctum')->user() ?? auth('api')->user(); // No user or admin - return all tags if (!$user || $user->hasRole('Administrator')) { @@ -382,7 +382,10 @@ public function makeMemberAHost($groupMember) public function getShareableLinkAttribute() { if (! empty($this->shareable_code)) { - return url("group/invite/{$this->shareable_code}"); + // Points at the SPA, not this Laravel app: the client claims it statelessly via + // POST /api/v2/invites/claim (or the invite_code param on login/register) - see + // App\Http\Controllers\API\AuthController::claimShareableCode(). + return rtrim(config('restarters.frontend_url'), '/')."/group/invite/{$this->shareable_code}"; } return ''; @@ -438,6 +441,35 @@ public function groupImagePath() return url('/uploads/mid_1474993329ef38d3a4b9478841cc2346f8e131842fdcfd073b307.jpg'); } + /** + * Unlike groupImagePath(), returns null (rather than a placeholder image) when the group + * has no image of its own - callers that want to distinguish "no image" from "has an image" + * (e.g. the v2 dashboard/nearby-groups API shapes) should use this instead. + */ + public function realImageUrl(): ?string + { + if (is_object($this->groupImage) && is_object($this->groupImage->image)) { + return url('/uploads/mid_'.$this->groupImage->image->path); + } + + return null; + } + + /** + * The {id, name, distance, location, image_url} shape used by the v2 dashboard/nearby-groups + * endpoints. Requires $this->distance to have been set (see User::groupsNearby()). + */ + public function toNearbySummary(): array + { + return [ + 'id' => $this->idgroups, + 'name' => $this->name, + 'distance' => $this->distance, + 'location' => $this->location, + 'image_url' => $this->realImageUrl(), + ]; + } + public function nextUpcomingParty(): HasOne { return $this->hasOne(Party::class, 'group', 'idgroups') diff --git a/app/Helpers/Fixometer.php b/app/Helpers/Fixometer.php index 755d7a63a0..051b63404a 100644 --- a/app/Helpers/Fixometer.php +++ b/app/Helpers/Fixometer.php @@ -748,6 +748,16 @@ public static function allBarriers() * @param [type] $model * @return [type] */ + /** + * A 24-character hex token, used for recovery codes and invite/RSVP + * hashes. Extracted from ten byte-identical inline copies across the API + * controllers (2026-07 API audit). + */ + public static function generateHash(): string + { + return substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24); + } + public static function generateUniqueShareableCode($model, $column) { do { diff --git a/app/Helpers/FixometerFile.php b/app/Helpers/FixometerFile.php index d8052e3f3d..9fd588ab06 100644 --- a/app/Helpers/FixometerFile.php +++ b/app/Helpers/FixometerFile.php @@ -66,107 +66,162 @@ public function upload($file, $type, $reference = null, $referenceType = null, $ /** if we have no error, proceed to elaborate and upload **/ if ($error == UPLOAD_ERR_OK) { - $filename = $this->filename($tmp_name); - $this->file = $filename; - $lpath = $_SERVER['DOCUMENT_ROOT'].'/uploads/'.$filename; - - // Attempt the move BEFORE touching existing records — if the move fails - // (e.g. uploads directory missing/unwritable) we preserve the old image. - if (!$this->move($tmp_name, $lpath)) { - return false; - } + return $this->processLocalFile($tmp_name, $type, $reference, $referenceType, $clear, $profile, $crop, true); + } - // Move succeeded — safe to remove previous image records. - if ($clear) { - Xref::where('reference', $reference) - ->where('reference_type', $referenceType) - ->forceDelete(); - } + return null; + } + + /** + * Ingest a file that is already sitting on local disk (i.e. NOT a PHP/HTTP + * upload for the current request, so move_uploaded_file() would refuse it) - + * e.g. a file assembled by a tus resumable-upload server. Runs the same + * validation/thumbnailing/DB-record pipeline as upload(), but copies the + * source file into place instead of using move_uploaded_file(). + * + * The caller is responsible for having already validated $localPath is a + * real, acceptable image before calling this (this method still re-checks + * the MIME type itself via filename(), same as upload() does). + * + * @param bool $clear Purge any pre-existing image(s) for this reference before attaching + * this one. True (the default) gives single-image semantics, matching + * the group/profile-photo callers. Event/device photos are a gallery + * (multiple images per reference, mirroring upload()'s $multiple=true + * path), so those callers must pass false. + */ + public function uploadLocalFile(string $localPath, $type, $reference = null, $referenceType = null, $profile = false, $crop = true, $clear = true) + { + // Same gate as upload(): preview/staging environments sharing the + // production bucket disable writes via this flag. uploadLocalFile is + // the path every /api/v2 image endpoint uses, so it must honour it too + // (returning null makes the callers surface their image_upload_error). + if (! config('restarters.features.image_upload')) { + return null; + } + + if (! is_file($localPath)) { + return null; + } + + return $this->processLocalFile($localPath, $type, $reference, $referenceType, $clear, $profile, $crop, false); + } + + /** + * Shared tail-end of upload()/uploadLocalFile(): validate the MIME type, + * move/copy the source file into the uploads directory, generate + * thumbnails, and create the images/xref DB records. + * + * @param bool $useUploadedFileMove true for real PHP uploads (move_uploaded_file(), + * or copy() under FixometerFile::$uploadTesting), + * false to always use a plain copy() (tus/local files). + */ + protected function processLocalFile(string $tmp_name, $type, $reference, $referenceType, bool $clear, bool $profile, bool $crop, bool $useUploadedFileMove) + { + $filename = $this->filename($tmp_name); + + if (! $filename) { + return false; + } + + $this->file = $filename; + $lpath = $_SERVER['DOCUMENT_ROOT'].'/uploads/'.$filename; + + // Attempt the move BEFORE touching existing records — if the move fails + // (e.g. uploads directory missing/unwritable) we preserve the old image. + $moved = $useUploadedFileMove ? $this->move($tmp_name, $lpath) : @copy($tmp_name, $lpath); + + if (! $moved) { + return false; + } - $data = []; - $this->path = $lpath; - $data['path'] = $this->file; + // Move succeeded — safe to remove previous image records. + if ($clear) { + Xref::where('reference', $reference) + ->where('reference_type', $referenceType) + ->forceDelete(); + } + + $data = []; + $this->path = $lpath; + $data['path'] = $this->file; + + // Fix orientation + Image::make($lpath)->orientate()->save($lpath); + + if ($type !== 'image') { + $this->syncToCloud($filename); + } - // Fix orientation - Image::make($lpath)->orientate()->save($lpath); + if ($type === 'image') { + $size = getimagesize($this->path); + $data['width'] = $size[0]; + $data['height'] = $size[1]; - if ($type !== 'image') { - $this->syncToCloud($filename); + if ($profile) { + $data['alt_text'] = 'Profile Picture'; } - if ($type === 'image') { - $size = getimagesize($this->path); - $data['width'] = $size[0]; - $data['height'] = $size[1]; - - if ($profile) { - $data['alt_text'] = 'Profile Picture'; - } - - if ($data['width'] > $data['height']) { - $biggestSide = $data['width']; - $resize_height = true; - } else { - $biggestSide = $data['height']; - $resize_height = false; - } - - $thumbSize = 80; - $midSize = 260; - - // Let's make images, which we will resize or crop - $thumb = Image::make($lpath); - $mid = Image::make($lpath); - - if ($resize_height) { // Resize before crop - $thumb->resize(null, $thumbSize, function ($constraint) { - $constraint->aspectRatio(); - }); - - $mid->resize(null, $midSize, function ($constraint) { - $constraint->aspectRatio(); - }); - } else { - $thumb->resize($thumbSize, null, function ($constraint) { - $constraint->aspectRatio(); - }); - - $mid->resize($midSize, null, function ($constraint) { - $constraint->aspectRatio(); - }); - } - - if ($crop) { - $thumb->crop($thumbSize, $thumbSize); - $mid->crop($midSize, $midSize); - } - - $thumb->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'thumbnail_'.$filename, 85); - $mid->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'mid_'.$filename, 85); - - $this->syncToCloud($filename); - $this->syncToCloud('thumbnail_'.$filename); - $this->syncToCloud('mid_'.$filename); - - $this->table = 'images'; - $Images = new Images; - - $image = $Images->create($data)->id; - - if (is_numeric($image) && ! is_null($reference) && ! is_null($referenceType)) { - Xref::create([ - 'object' => $image, - 'object_type' => env('TBL_IMAGES'), - 'reference' => $reference, - 'reference_type' => $referenceType, - ]); - } + if ($data['width'] > $data['height']) { + $biggestSide = $data['width']; + $resize_height = true; + } else { + $biggestSide = $data['height']; + $resize_height = false; } - return $filename; + $thumbSize = 80; + $midSize = 260; + + // Let's make images, which we will resize or crop + $thumb = Image::make($lpath); + $mid = Image::make($lpath); + + if ($resize_height) { // Resize before crop + $thumb->resize(null, $thumbSize, function ($constraint) { + $constraint->aspectRatio(); + }); + + $mid->resize(null, $midSize, function ($constraint) { + $constraint->aspectRatio(); + }); + } else { + $thumb->resize($thumbSize, null, function ($constraint) { + $constraint->aspectRatio(); + }); + + $mid->resize($midSize, null, function ($constraint) { + $constraint->aspectRatio(); + }); + } + + if ($crop) { + $thumb->crop($thumbSize, $thumbSize); + $mid->crop($midSize, $midSize); + } + + $thumb->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'thumbnail_'.$filename, 85); + $mid->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'mid_'.$filename, 85); + + $this->syncToCloud($filename); + $this->syncToCloud('thumbnail_'.$filename); + $this->syncToCloud('mid_'.$filename); + + $this->table = 'images'; + $Images = new Images; + + $image = $Images->create($data)->id; + + if (is_numeric($image) && ! is_null($reference) && ! is_null($referenceType)) { + Xref::create([ + 'object' => $image, + 'object_type' => env('TBL_IMAGES'), + 'reference' => $reference, + 'reference_type' => $referenceType, + ]); + } } - return null; + return $filename; } /** diff --git a/app/Helpers/Tus.php b/app/Helpers/Tus.php new file mode 100644 index 0000000000..ecdfd74209 --- /dev/null +++ b/app/Helpers/Tus.php @@ -0,0 +1,86 @@ +setPrefix('tus:server:'); + + return $cache; + } + + /** + * Build a tus-php Server configured identically wherever it's used (the + * TusController that serves the protocol, and the API controller that + * later looks up a completed upload by key). Using a shared factory keeps + * the upload dir/cache dir/api path in sync between the two call sites. + * + * Only call this where a real Request is available/expected (i.e. the actual tus + * protocol route) - see buildCache() for cache-only access. + */ + public static function buildServer(): Server + { + $uploadDir = self::uploadDir(); + + if (! is_dir($uploadDir)) { + mkdir($uploadDir, 0775, true); + } + + $server = new Server(self::buildCache()); + $server->setUploadDir($uploadDir); + $server->setApiPath('/api/tus'); + + // This route is intentionally unauthenticated (see TusController), so cap what an + // anonymous client can push to disk here even though updateMyPhotov2() separately + // enforces its own 2MB limit once a completed upload is claimed. A little headroom + // over the app-level limit avoids rejecting legitimate uploads before compression. + $server->setMaxUploadSize(10 * 1024 * 1024); + + return $server; + } +} diff --git a/app/Http/Controllers/API/AlertController.php b/app/Http/Controllers/API/AlertController.php index 26bda7d946..9592e8b62b 100644 --- a/app/Http/Controllers/API/AlertController.php +++ b/app/Http/Controllers/API/AlertController.php @@ -59,11 +59,11 @@ public function listAlertsv2(Request $request) { * operationId="createAlert", * tags={"Alerts"}, * summary="Create Alert", - * description="Creates an alert.", + * description="Creates an alert. Administrator only. Note: getUser() throws AuthenticationException (HTTP 401) for both an unauthenticated caller and an authenticated non-Administrator, so this operation never returns 403 - unlike updateAlertv2, which does distinguish the two.", * @OA\Parameter( * name="api_token", - * description="A valid user API token", - * required=true, + * description="A valid user API token, if not authenticating via a session cookie or a Sanctum bearer token (see getUser(), which tries all three).", + * required=false, * in="query", * @OA\Schema( * type="string", @@ -71,35 +71,10 @@ public function listAlertsv2(Request $request) { * ) * ), * @OA\RequestBody( + * required=true, * @OA\MediaType( * mediaType="multipart/form-data", - * @OA\Schema( - * required={"title","html","start","end"}, - * @OA\Property( - * property="title", - * ref="#/components/schemas/Alert/properties/title", - * ), - * @OA\Property( - * property="html", - * ref="#/components/schemas/Alert/properties/html", - * ), - * @OA\Property( - * property="start", - * ref="#/components/schemas/Alert/properties/start", - * ), - * @OA\Property( - * property="end", - * ref="#/components/schemas/Alert/properties/end", - * ), - * @OA\Property( - * property="ctatitle", - * ref="#/components/schemas/Alert/properties/ctalink", - * ), - * @OA\Property( - * property="ctalink", - * ref="#/components/schemas/Alert/properties/ctalink", - * ), - * ) + * @OA\Schema(ref="#/components/schemas/AlertInput") * ) * ), * @OA\Response( @@ -112,7 +87,9 @@ public function listAlertsv2(Request $request) { * ref="#/components/schemas/Alert/properties/id" * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function addAlertv2(Request $request) @@ -134,6 +111,10 @@ public function addAlertv2(Request $request) 'ctalink' => $ctalink ])->id; + // Invalidate the 7200s listAlertsv2 cache so a new alert appears immediately + // (create previously never forgot the key, hiding new alerts for up to 2h). + \Cache::forget('alerts'); + return [ 'id' => $id ]; @@ -145,11 +126,17 @@ public function addAlertv2(Request $request) * operationId="updateAlert", * tags={"Alerts"}, * summary="Edit Alert", - * description="Edits an alert.", + * description="Edits an alert. Administrator only.", * @OA\Parameter( - * name="api_token", - * description="A valid user API token", + * name="id", + * in="path", * required=true, + * @OA\Schema(type="integer") + * ), + * @OA\Parameter( + * name="api_token", + * description="A valid user API token, if not authenticating via a session cookie or a Sanctum bearer token (see getUser(), which tries all three).", + * required=false, * in="query", * @OA\Schema( * type="string", @@ -157,35 +144,10 @@ public function addAlertv2(Request $request) * ) * ), * @OA\RequestBody( + * required=true, * @OA\MediaType( * mediaType="multipart/form-data", - * @OA\Schema( - * required={"title","html","start","end"}, - * @OA\Property( - * property="title", - * ref="#/components/schemas/Alert/properties/title", - * ), - * @OA\Property( - * property="html", - * ref="#/components/schemas/Alert/properties/html", - * ), - * @OA\Property( - * property="start", - * ref="#/components/schemas/Alert/properties/start", - * ), - * @OA\Property( - * property="end", - * ref="#/components/schemas/Alert/properties/end", - * ), - * @OA\Property( - * property="ctatitle", - * ref="#/components/schemas/Alert/properties/ctalink", - * ), - * @OA\Property( - * property="ctalink", - * ref="#/components/schemas/Alert/properties/ctalink", - * ), - * ) + * @OA\Schema(ref="#/components/schemas/AlertInput") * ) * ), * @OA\Response( @@ -198,7 +160,11 @@ public function addAlertv2(Request $request) * ref="#/components/schemas/Alert/properties/id" * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function updateAlertv2(Request $request, $id) @@ -222,31 +188,15 @@ public function updateAlertv2(Request $request, $id) 'ctalink' => $ctalink ]); - \Cache::clear('alerts'); + // Cache::clear() ignores its argument and flushes the ENTIRE cache store; + // forget only the alerts key. + \Cache::forget('alerts'); return [ 'id' => $id ]; } - private function getUser() - { - // We want to allow this call to work if a) we are logged in as a user, or b) we have a valid API token. - // - // This is a slightly odd thing to do, but it is necessary to get both the PHPUnit tests and the - // real client use of the API to work. - $user = Auth::user(); - - if (!$user) { - $user = auth('api')->user(); - } - - if (!$user) { - throw new AuthenticationException(); - } - - return $user; - } private function validateAlertParams(Request $request, $create): array { diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php new file mode 100644 index 0000000000..a6a17427d8 --- /dev/null +++ b/app/Http/Controllers/API/AuthController.php @@ -0,0 +1,746 @@ +validate([ + 'email' => 'required|string|email', + 'password' => 'required|string', + // Honeypot: real users never see or fill this field. + 'my_name' => 'nullable|prohibited', + 'invite_code' => 'nullable|string', + 'invite_type' => 'nullable|string|in:group,event|required_with:invite_code', + 'invite_hash' => 'nullable|string', + ]); + + $credentials = $request->only('email', 'password'); + + if (! Auth::guard('web')->validate($credentials)) { + throw ValidationException::withMessages([ + 'email' => [__('auth.failed')], + ]); + } + + $user = User::where('email', $credentials['email'])->firstOrFail(); + + $this->recordLogin($user); + $invite = $this->claimInvitesFor($user, $request); + + return response()->json([ + 'data' => [ + 'token' => $user->createToken('spa')->plainTextToken, + 'user' => self::userSummary($user), + 'invite' => $invite, + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/auth/logout", + * operationId="logoutv2", + * tags={"Auth"}, + * summary="Revoke the current token and sync logout to Discourse/Wiki", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Logged out", + * @OA\JsonContent(@OA\Property(property="message", type="string")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function logoutv2(Request $request): JsonResponse + { + $user = $request->user(); + + // Sanctum-issued tokens are revoked; legacy api_token credentials have + // no revocation (they are not per-session), so there is nothing to do + // for them here. + $token = $user->currentAccessToken(); + if ($token instanceof \Laravel\Sanctum\PersonalAccessToken) { + $token->delete(); + } + + // Fires LogOutOfWiki (cookie forget; harmless on XHR) and the queued + // Discourse force-logout listener. + event(new Logout('web', $user)); + + return response()->json(['message' => 'Logged out.']); + } + + /** + * @OA\Post( + * path="/api/v2/auth/register", + * operationId="registerv2", + * tags={"Auth"}, + * summary="Register a new user, returning a bearer token", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name","email","password","password_confirmation","age","country","consent_gdpr","consent_future_data"}, + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string", format="email"), + * @OA\Property(property="password", type="string", format="password"), + * @OA\Property(property="password_confirmation", type="string", format="password"), + * @OA\Property(property="age", type="string"), + * @OA\Property(property="country", type="string", description="Country code"), + * @OA\Property(property="city", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="skills", type="array", nullable=true, @OA\Items(type="integer")), + * @OA\Property(property="newsletter", type="boolean", nullable=true), + * @OA\Property(property="invites", type="boolean", nullable=true), + * @OA\Property(property="consent_gdpr", type="boolean"), + * @OA\Property(property="consent_future_data", type="boolean"), + * @OA\Property(property="invite_code", type="string", nullable=true), + * @OA\Property(property="invite_type", type="string", nullable=true, enum={"group","event"}), + * @OA\Property(property="invite_hash", type="string", nullable=true), + * @OA\Property(property="my_name", type="string", nullable=true, description="Honeypot; must be empty") + * ) + * ), + * @OA\Response( + * response=201, + * description="Registered", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="token", type="string"), + * @OA\Property(property="user", type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string") + * ) + * ) + * ) + * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * @OA\Response(response=429, ref="#/components/responses/TooManyRequests") + * ) + */ + public function registerv2(Request $request): JsonResponse + { + // Mirrors the rules of the Blade registration form (UserController:: + // postRegister), minus honeytime (which needs a server-rendered form + // field) and plus the stateless invite parameters. + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:6|confirmed', + 'age' => 'required', + 'country' => 'required', + 'city' => 'nullable|string', + 'gender' => 'nullable|string', + 'skills' => 'nullable|array', + 'newsletter' => 'nullable|boolean', + 'invites' => 'nullable|boolean', + 'consent_gdpr' => 'required|accepted', + 'consent_future_data' => 'required|accepted', + 'my_name' => 'nullable|prohibited', + 'invite_code' => 'nullable|string', + 'invite_type' => 'nullable|string|in:group,event|required_with:invite_code', + 'invite_hash' => 'nullable|string', + ]); + + $skills = $request->input('skills'); + + $user = User::create([ + 'name' => $request->input('name'), + 'email' => $request->input('email'), + 'password' => Hash::make($request->input('password')), + 'recovery' => Fixometer::generateHash(), + 'recovery_expires' => date('Y-m-d H:i:s', time() + (24 * 60 * 60)), + 'country_code' => $request->input('country'), + 'location' => $request->input('city'), + 'gender' => $request->input('gender'), + 'age' => $request->input('age'), + 'calendar_hash' => Str::random(15), + 'username' => '', + ]); + + // role excluded from $fillable (security); set via direct assignment. + $user->role = Fixometer::skillsDetermineRole($skills); + $user->generateAndSetUsername(); + + $user->recordConsent(['consent_gdpr', 'consent_future_data'], $request->boolean('newsletter')); + + if ($request->boolean('invites')) { + $user->invites = 1; + } + + if ($request->filled('city')) { + $geocoded = app(Geocoder::class)->geocode("{$request->input('city')}, {$request->input('country')}"); + if (! empty($geocoded)) { + $user->latitude = $geocoded['latitude']; + $user->longitude = $geocoded['longitude']; + } + } + + // Wiki account creation is attempted when the Login event next fires, + // i.e. at the first GET /auth/bridge navigation (Talk/Wiki link-out). + $user->wiki_sync_status = WikiSyncStatus::CreateAtLogin; + $user->save(); + + $notify_users = Fixometer::usersWhoHavePreference('admin-new-user'); + Notification::send($notify_users, new AdminNewUser([ + 'id' => $user->id, + 'name' => $user->name, + ])); + + if (! empty($skills)) { + $user->skillsold()->sync($skills); + } + + $invite = $this->claimInvitesFor($user, $request); + + event(new UserRegistered($user)); + + $this->recordLogin($user); + + return response()->json([ + 'data' => [ + 'token' => $user->createToken('spa')->plainTextToken, + 'user' => self::userSummary($user), + 'invite' => $invite, + ], + ], 201); + } + + /** + * @OA\Post( + * path="/api/v2/auth/password/forgot", + * operationId="forgotPasswordv2", + * tags={"Auth"}, + * summary="Send a password recovery email", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"email"}, + * @OA\Property(property="email", type="string", format="email") + * ) + * ), + * @OA\Response( + * response=200, + * description="Recovery email sent", + * @OA\JsonContent(@OA\Property(property="message", type="string")) + * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * @OA\Response(response=429, ref="#/components/responses/TooManyRequests") + * ) + */ + public function forgotPasswordv2(Request $request): JsonResponse + { + $request->validate(['email' => 'required|string|email']); + + $user = User::where('email', $request->input('email'))->first(); + + if (! $user) { + // Parity with the Blade flow, which also discloses whether the + // account exists (passwords.user message). + throw ValidationException::withMessages([ + 'email' => [__('passwords.user')], + ]); + } + + $user->update([ + 'recovery' => Fixometer::generateHash(), + 'recovery_expires' => date('Y-m-d H:i:s', time() + (24 * 60 * 60)), + ]); + + // /user/reset remains a Laravel URL: it serves the Blade page today + // and becomes a redirector into the SPA at cutover, so emailed links + // keep working throughout. + $user->notify(new ResetPassword([ + 'url' => url('/user/reset?recovery='.$user->recovery), + ])); + + return response()->json(['message' => __('passwords.sent')]); + } + + /** + * @OA\Post( + * path="/api/v2/auth/password/reset", + * operationId="resetPasswordv2", + * tags={"Auth"}, + * summary="Reset a password using an emailed recovery token", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"recovery","password","password_confirmation"}, + * @OA\Property(property="recovery", type="string"), + * @OA\Property(property="password", type="string", format="password"), + * @OA\Property(property="password_confirmation", type="string", format="password") + * ) + * ), + * @OA\Response( + * response=200, + * description="Password updated", + * @OA\JsonContent(@OA\Property(property="message", type="string")) + * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * @OA\Response(response=429, ref="#/components/responses/TooManyRequests") + * ) + */ + public function resetPasswordv2(Request $request): JsonResponse + { + $request->validate([ + 'recovery' => 'required|string', + 'password' => 'required|string|min:6|confirmed', + ]); + + $user = User::findByValidRecoveryToken($request->input('recovery')); + + if (! $user) { + throw ValidationException::withMessages([ + 'recovery' => [__('passwords.token')], + ]); + } + + $oldPassword = $user->password; + $user->update([ + 'password' => Hash::make($request->input('password')), + // Rotate the recovery token so the just-used reset link cannot be + // replayed within its 24h window (an intercepted/forwarded link + // must be single-use). Mirrors UserController::updateMyPasswordv2. + 'recovery' => Fixometer::generateHash(), + 'recovery_expires' => strftime('%Y-%m-%d %X', time() + (24 * 60 * 60)), + ]); + + event(new PasswordChanged($user, $oldPassword)); + + return response()->json(['message' => __('passwords.updated')]); + } + + /** + * @OA\Get( + * path="/api/v2/auth/password/recovery/{token}", + * operationId="recoveryInfov2", + * tags={"Auth"}, + * summary="Check a password recovery token before the reset form is submitted", + * description="Used by the reset-password page on load, mirroring the Blade recovery page which validated the token up front and pre-filled a disabled account-email field.", + * @OA\Parameter(name="token", in="path", required=true, @OA\Schema(type="string")), + * @OA\Response( + * response=200, + * description="Token validity and, when valid, the owning account's email", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="valid", type="boolean"), + * @OA\Property(property="email", type="string", nullable=true) + * ) + * ) + * ), + * @OA\Response(response=429, ref="#/components/responses/TooManyRequests") + * ) + */ + public function recoveryInfov2(string $token): JsonResponse + { + $user = User::findByValidRecoveryToken($token); + + return response()->json([ + 'data' => [ + 'valid' => $user !== null, + 'email' => $user->email ?? null, + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/auth/email-available", + * operationId="emailAvailablev2", + * tags={"Auth"}, + * summary="Check whether an email address is available for registration", + * @OA\Parameter(name="email", in="query", required=true, @OA\Schema(type="string", format="email")), + * @OA\Response( + * response=200, + * description="Availability", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="available", type="boolean"), + * @OA\Property(property="message", type="string") + * ) + * ) + * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function emailAvailablev2(Request $request): JsonResponse + { + $request->validate(['email' => 'required|string|email']); + + $available = ! User::where('email', $request->input('email'))->exists(); + + return response()->json([ + 'data' => [ + 'available' => $available, + 'message' => $available ? 'Email is available' : __('auth.email_address_validation'), + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/invites/claim", + * operationId="claimInvitev2", + * tags={"Auth"}, + * summary="Claim a shareable invite code or emailed invite hash for the current user", + * description="Stateless replacement for the session-bridged shareable-link flow (GET /group/invite/{code}, /party/invite/{code}) and the AcceptUserInvites middleware.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="invite_code", type="string", nullable=true), + * @OA\Property(property="invite_type", type="string", nullable=true, enum={"group","event"}), + * @OA\Property(property="invite_hash", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Invite applied (or already a member)", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="type", type="string", nullable=true, enum={"group","event"}), + * @OA\Property(property="id", type="integer", nullable=true), + * @OA\Property(property="already_member", type="boolean", nullable=true) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function claimInvitev2(Request $request): JsonResponse + { + $request->validate([ + 'invite_code' => 'nullable|string|required_without:invite_hash', + 'invite_type' => 'nullable|string|in:group,event|required_with:invite_code', + 'invite_hash' => 'nullable|string|required_without:invite_code', + ]); + + $result = $this->claimInvitesFor($request->user(), $request); + + if (! $result) { + abort(404, 'Unknown invite code or hash.'); + } + + return response()->json(['data' => $result]); + } + + /** + * @OA\Post( + * path="/api/v2/auth/sso-ticket", + * operationId="ssoTicketv2", + * tags={"Auth"}, + * summary="Issue a one-time ticket for establishing a web session at GET /auth/bridge", + * description="Used before top-level navigations to Talk (Discourse SSO) or the Wiki, which need a Laravel web session. Tickets are single-use and expire after 60 seconds.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Ticket issued", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="ticket", type="string"), + * @OA\Property(property="bridge_url", type="string") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function ssoTicketv2(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'ticket' => \App\SsoTicket::issue($request->user()), + 'bridge_url' => url('/auth/bridge'), + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/auth/consent", + * operationId="consentv2", + * tags={"Auth"}, + * summary="Record outstanding data consents (and profile basics) for the current user", + * description="Port of the logged-in branch of the Blade registration form, which doubles as the consent-completion form for users gated by VerifyUserConsent.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"age","country","consent_gdpr","consent_past_data","consent_future_data"}, + * @OA\Property(property="age", type="string"), + * @OA\Property(property="country", type="string"), + * @OA\Property(property="city", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="consent_gdpr", type="boolean"), + * @OA\Property(property="consent_past_data", type="boolean"), + * @OA\Property(property="consent_future_data", type="boolean"), + * @OA\Property(property="newsletter", type="boolean", nullable=true, description="Opt in to the newsletter; omitted/false leaves the existing preference unchanged") + * ) + * ), + * @OA\Response( + * response=200, + * description="Consents recorded; returns the refreshed session payload", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="user", type="object", nullable=true), + * @OA\Property(property="config", type="object"), + * @OA\Property(property="flags", type="object") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function consentv2(Request $request): JsonResponse + { + $request->validate([ + 'age' => 'required', + 'country' => 'required', + 'city' => 'nullable|string', + 'gender' => 'nullable|string', + 'consent_gdpr' => 'required|accepted', + 'consent_past_data' => 'required|accepted', + 'consent_future_data' => 'required|accepted', + 'newsletter' => 'sometimes|boolean', + ]); + + $user = $request->user(); + + $user->country_code = $request->input('country'); + $user->age = $request->input('age'); + + if ($request->filled('city')) { + $user->location = $request->input('city'); + $geocoded = app(Geocoder::class)->geocode("{$request->input('city')}, {$request->input('country')}"); + if (! empty($geocoded)) { + $user->latitude = $geocoded['latitude']; + $user->longitude = $geocoded['longitude']; + } + } + + if ($request->filled('gender')) { + $user->gender = $request->input('gender'); + } + + $user->recordConsent(['consent_gdpr', 'consent_past_data', 'consent_future_data'], $request->boolean('newsletter')); + $user->save(); + + return response()->json(['data' => SessionController::sessionPayload($user->fresh())]); + } + + /** + * The LogSuccessfulLogin listener's behaviour, invoked directly because we + * intentionally do not fire the Login event on the XHR path (see class + * docblock). + */ + private function recordLogin(User $user): void + { + $user->last_login_at = Carbon::now()->toDateTimeString(); + $user->number_of_logins += 1; + $user->save(); + } + + private static function userSummary(User $user): array + { + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ]; + } + + /** + * Apply any invite identifiers carried on the request to $user, statelessly. + * Returns ['type' =>, 'id' =>, 'already_member' =>] or null if nothing claimed. + */ + private function claimInvitesFor(User $user, Request $request): ?array + { + if ($request->filled('invite_code')) { + return $this->claimShareableCode($user, $request->input('invite_code'), $request->input('invite_type')); + } + + if ($request->filled('invite_hash')) { + return $this->claimInviteHash($user, $request->input('invite_hash')); + } + + return null; + } + + private function claimShareableCode(User $user, string $code, string $type): ?array + { + if ($type === 'group') { + $group = Group::where('shareable_code', $code)->first(); + + if (! $group) { + return null; + } + + if ($group->isVolunteer($user->id)) { + return ['type' => 'group', 'id' => $group->idgroups, 'name' => $group->name, 'already_member' => true]; + } + + UserGroups::updateOrCreate([ + 'user' => $user->id, + 'group' => $group->idgroups, + ], [ + 'status' => '1', + 'role' => 4, + ]); + + return ['type' => 'group', 'id' => $group->idgroups, 'name' => $group->name, 'already_member' => false]; + } + + $party = Party::where('shareable_code', $code)->first(); + + if (! $party) { + return null; + } + + if ($party->isVolunteer($user->id)) { + return ['type' => 'event', 'id' => $party->idevents, 'name' => $party->getEventName(), 'already_member' => true]; + } + + EventsUsers::updateOrCreate([ + 'user' => $user->id, + 'event' => $party->idevents, + ], [ + 'status' => '1', + 'role' => 4, + ]); + + return ['type' => 'event', 'id' => $party->idevents, 'name' => $party->getEventName(), 'already_member' => false]; + } + + private function claimInviteHash(User $user, string $hash): ?array + { + $acceptance = Invite::where('hash', $hash)->first(); + + if (! $acceptance) { + return null; + } + + if ($acceptance->type === 'event') { + $already = Party::find($acceptance->record_id)?->isVolunteer($user->id) ?? false; + if (! $already) { + EventsUsers::updateOrCreate([ + 'user' => $user->id, + 'event' => $acceptance->record_id, + ], [ + 'status' => '1', + 'role' => 4, + ]); + } + $acceptance->delete(); + + return ['type' => 'event', 'id' => (int) $acceptance->record_id, 'already_member' => $already]; + } + + $group = Group::find($acceptance->record_id); + $already = $group?->isVolunteer($user->id) ?? false; + + if (! $already && $group) { + UserGroups::updateOrCreate([ + 'user' => $user->id, + 'group' => $group->idgroups, + ], [ + 'status' => '1', + 'role' => 4, + ]); + + // Parity with the emailed-invite flow: let hosts know someone joined. + $group_hosts = $group->membersHosts(); + if ($group_hosts->count()) { + Notification::send($group_hosts->get(), new NewGroupMember([ + 'user_name' => $user->name, + 'group_name' => $group->name, + 'group_url' => url('/group/view/'.$group->idgroups), + ])); + } + } + $acceptance->delete(); + + return ['type' => 'group', 'id' => (int) $acceptance->record_id, 'already_member' => $already]; + } +} diff --git a/app/Http/Controllers/API/BrandController.php b/app/Http/Controllers/API/BrandController.php new file mode 100644 index 0000000000..e5279a2940 --- /dev/null +++ b/app/Http/Controllers/API/BrandController.php @@ -0,0 +1,187 @@ +get(); + + return BrandCollection::make($brands); + } + + /** + * @OA\Get( + * path="/api/v2/brands/{id}", + * operationId="getBrandv2", + * tags={"Brands"}, + * summary="Get a Brand", + * description="Returns a single brand by id.", + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getBrandv2($id) + { + $brand = Brands::findOrFail($id); + + return Brand::make($brand); + } + + /** + * @OA\Post( + * path="/api/v2/brands", + * operationId="createBrandv2", + * tags={"Brands"}, + * summary="Create a Brand", + * description="Create a new device brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"brand_name"}, + * @OA\Property(property="brand_name", type="string", maxLength=255, example="Sony") + * ) + * ), + * @OA\Response( + * response=201, + * description="Brand created", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function createBrandv2(Request $request): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $validated = $request->validate([ + 'brand_name' => 'required|string|max:255|unique:brands,brand_name', + ]); + + $brand = Brands::create($validated); + + return response()->json(['data' => (new Brand($brand))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/brands/{id}", + * operationId="updateBrandv2", + * tags={"Brands"}, + * summary="Update a Brand", + * description="Update a brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"brand_name"}, + * @OA\Property(property="brand_name", type="string", maxLength=255, example="Sony") + * ) + * ), + * @OA\Response( + * response=200, + * description="Brand updated", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateBrandv2(Request $request, $id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $brand = Brands::findOrFail($id); + + $validated = $request->validate([ + 'brand_name' => 'required|string|max:255|unique:brands,brand_name,' . $brand->id, + ]); + + $brand->update($validated); + + return Brand::make($brand->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/brands/{id}", + * operationId="deleteBrandv2", + * tags={"Brands"}, + * summary="Delete a Brand", + * description="Delete a brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Brand deleted"), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteBrandv2($id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $brand = Brands::findOrFail($id); + $brand->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/API/CategoryController.php b/app/Http/Controllers/API/CategoryController.php new file mode 100644 index 0000000000..6321260100 --- /dev/null +++ b/app/Http/Controllers/API/CategoryController.php @@ -0,0 +1,170 @@ +categoriesWithClusterName() + ->orderBy('categories.name', 'asc') + ->get(); + + return CategoryCollection::make($categories); + } + + /** + * @OA\Get( + * path="/api/v2/categories/{id}", + * operationId="getCategoryv2", + * tags={"Categories"}, + * summary="Get a Category", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Category")) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getCategoryv2($id) + { + $category = $this->categoriesWithClusterName() + ->where('categories.idcategories', $id) + ->firstOrFail(); + + return CategoryResource::make($category); + } + + /** + * @OA\Put( + * path="/api/v2/categories/{id}", + * operationId="updateCategoryv2", + * tags={"Categories"}, + * summary="Update a Category", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255), + * @OA\Property(property="weight", type="number", format="float", nullable=true), + * @OA\Property(property="footprint", type="number", format="float", nullable=true), + * @OA\Property(property="footprint_reliability", type="integer", minimum=1, maximum=6, nullable=true), + * @OA\Property(property="cluster", type="integer", nullable=true), + * @OA\Property(property="description_short", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Category updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Category")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateCategoryv2(Request $request, $id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $category = Category::findOrFail($id); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'weight' => ['nullable', 'numeric', 'min:0'], + 'footprint' => ['nullable', 'numeric', 'min:0'], + 'footprint_reliability' => ['nullable', 'integer', Rule::in([1, 2, 3, 4, 5, 6])], + 'cluster' => ['nullable', 'integer'], + 'description_short' => ['nullable', 'string'], + ]); + + $category->update($validated); + + $fresh = $this->categoriesWithClusterName() + ->where('categories.idcategories', $category->idcategories) + ->firstOrFail(); + + return CategoryResource::make($fresh); + } + + /** + * @OA\Get( + * path="/api/v2/category-clusters", + * operationId="listCategoryClustersv2", + * tags={"Categories"}, + * summary="List category clusters", + * description="Returns the cluster table (parent groupings for categories). Public endpoint, used to populate the cluster dropdown on the admin page.", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property( + * property="data", + * type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="Computers and Home Office") + * ) + * ) + * ) + * ) + * ) + */ + public function listCategoryClustersv2(): JsonResponse + { + $rows = DB::select('SELECT idclusters AS id, name FROM clusters ORDER BY idclusters ASC'); + + return response()->json([ + 'data' => array_map(fn ($r) => ['id' => (int) $r->id, 'name' => $r->name], $rows), + ]); + } + + /** + * Build the base query for categories joined with their cluster name. + * Scopes to the current revision (matches the legacy admin views). + */ + private const CURRENT_REVISION = 2; + + private function categoriesWithClusterName() + { + return Category::query() + ->select('categories.*', 'clusters.name as cluster_name') + ->leftJoin('clusters', 'clusters.idclusters', '=', 'categories.cluster') + ->where('categories.revision', self::CURRENT_REVISION); + } +} diff --git a/app/Http/Controllers/API/DashboardController.php b/app/Http/Controllers/API/DashboardController.php new file mode 100644 index 0000000000..11a8b64093 --- /dev/null +++ b/app/Http/Controllers/API/DashboardController.php @@ -0,0 +1,152 @@ +user(); + + return response()->json([ + 'data' => [ + 'has_location' => ! is_null($user->latitude) && ! is_null($user->longitude), + 'your_groups' => self::yourGroups($user), + 'nearby_groups' => self::expandNearbyGroups($user->groupsNearby(2)), + 'new_nearby_groups' => self::expandNearbyGroups($user->groupsNearby(3, '1 month ago')), + 'upcoming_events' => self::upcomingEvents($user), + ], + ]); + } + + private static function yourGroups($user): array + { + // Groups where this user has a (not-deleted) users_groups pivot row. Eager-load the + // group image relation to avoid an N+1 query per group when building image_url below. + $groups = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups') + ->where('users_groups.user', $user->id) + ->whereNull('users_groups.deleted_at') + ->orderBy('groups.name', 'ASC') + ->groupBy('groups.idgroups', 'groups.name', 'users_groups.role', 'groups.archived_at') + ->select(['groups.idgroups', 'groups.name', 'users_groups.role', 'groups.archived_at']) + ->take(5) + ->with('groupImage.image') + ->get(); + + return $groups->map(function ($group) { + return [ + 'id' => $group->idgroups, + 'name' => $group->name, + 'role' => (int) $group->role, + 'archived' => ! is_null($group->archived_at), + 'archived_at' => $group->archived_at ? Carbon::parse($group->archived_at)->toIso8601String() : null, + 'image_url' => $group->realImageUrl(), + ]; + })->values()->all(); + } + + private static function expandNearbyGroups(iterable $groups): array + { + $ret = []; + + foreach ($groups as $group) { + $ret[] = $group->toNearbySummary(); + } + + return $ret; + } + + private static function upcomingEvents($user): array + { + $events = Party::futureForUser()->with('theGroup')->take(5)->get(); + + $ret = []; + + foreach ($events as $event) { + $group = $event->theGroup; + + $ret[] = [ + 'id' => $event->idevents, + 'title' => $event->venue ?? $event->location, + 'start' => $event->event_start_utc, + 'end' => $event->event_end_utc, + 'timezone' => $event->timezone, + 'online' => (bool) $event->online, + 'location' => $event->location, + 'attending' => $event->isBeingAttendedBy($user->id), + 'group' => $group ? [ + 'id' => $group->idgroups, + 'name' => $group->name, + ] : null, + ]; + } + + return $ret; + } +} diff --git a/app/Http/Controllers/API/DeviceController.php b/app/Http/Controllers/API/DeviceController.php index 2fed4219d8..4239c4be4d 100644 --- a/app/Http/Controllers/API/DeviceController.php +++ b/app/Http/Controllers/API/DeviceController.php @@ -8,9 +8,11 @@ use App\DeviceBarrier; use App\Events\DeviceCreatedOrUpdated; use App\Helpers\Fixometer; +use App\Helpers\Tus; use App\Http\Controllers\Controller; use App\Notifications\AdminAbnormalDevices; use App\Party; +use App\User; use App\Xref; use Illuminate\Auth\AuthenticationException; use Illuminate\Validation\ValidationException; @@ -28,7 +30,7 @@ class DeviceController extends Controller { * operationId="getDevice", * tags={"Devices"}, * summary="Get Device", - * description="Returns information about a device.", + * description="Returns information about a device. Public - no authentication required.", * @OA\Parameter( * name="id", * description="Device id", @@ -49,10 +51,7 @@ class DeviceController extends Controller { * ) * ) * ), - * @OA\Response( - * response=404, - * description="Device not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -69,17 +68,8 @@ public function getDevicev2(Request $request, $iddevices) * operationId="createDevice", * tags={"Devices"}, * summary="Create Device", - * description="Creates a device.", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Creates a device against an event. Requires edit-events-devices permission (typically the event's host) on the target event (`eventid`). 404 if that event does not exist.", + * security={{"apiToken":{}}}, * @OA\RequestBody( * @OA\MediaType( * mediaType="multipart/form-data", @@ -145,15 +135,38 @@ public function getDevicev2(Request $request, $iddevices) * ), * @OA\Response( * response=200, - * description="Successful operation", + * description="Successful operation. Returns the device and the owning event's stats, to save the client another API call to update its store.", * @OA\JsonContent( - * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Device" + * @OA\Property(property="id", type="integer", description="The id of the created device"), + * @OA\Property(property="device", ref="#/components/schemas/Device"), + * @OA\Property(property="stats", type="object", description="Party::getEventStats() for the device's event - the same shape as the stats block on GET /api/v2/events/{id}.", + * @OA\Property(property="co2_powered", type="number"), + * @OA\Property(property="co2_unpowered", type="number"), + * @OA\Property(property="co2_total", type="number"), + * @OA\Property(property="waste_powered", type="number"), + * @OA\Property(property="waste_unpowered", type="number"), + * @OA\Property(property="waste_total", type="number"), + * @OA\Property(property="fixed_devices", type="number"), + * @OA\Property(property="fixed_powered", type="number"), + * @OA\Property(property="fixed_unpowered", type="number"), + * @OA\Property(property="repairable_devices", type="number"), + * @OA\Property(property="dead_devices", type="number"), + * @OA\Property(property="unknown_repair_status", type="number"), + * @OA\Property(property="devices_powered", type="number"), + * @OA\Property(property="devices_unpowered", type="number"), + * @OA\Property(property="no_weight_powered", type="number"), + * @OA\Property(property="no_weight_unpowered", type="number"), + * @OA\Property(property="participants", type="number"), + * @OA\Property(property="volunteers", type="number"), + * @OA\Property(property="hours_volunteered", type="number"), + * @OA\Property(property="invited", type="number") * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function createDevicev2(Request $request) @@ -258,15 +271,15 @@ public function createDevicev2(Request $request) * operationId="editDevice", * tags={"Devices"}, * summary="Edit Device", - * description="Edits a device.", + * description="Edits a device. Requires edit-events-devices permission on both the target event (`eventid` in the body) and the device's current owning event - an IDOR guard against a host of one event reassigning/overwriting a device that belongs to another event.", + * security={{"apiToken":{}}}, * @OA\Parameter( - * name="api_token", - * description="A valid user API token", + * name="id", + * description="Device id", * required=true, - * in="query", + * in="path", * @OA\Schema( - * type="string", - * example="1234" + * type="integer" * ) * ), * @OA\RequestBody( @@ -334,15 +347,38 @@ public function createDevicev2(Request $request) * ), * @OA\Response( * response=200, - * description="Successful operation", + * description="Successful operation. Returns the device and the owning event's stats, to save the client another API call to update its store.", * @OA\JsonContent( - * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Device" + * @OA\Property(property="id", type="string", description="The id of the updated device"), + * @OA\Property(property="device", ref="#/components/schemas/Device"), + * @OA\Property(property="stats", type="object", description="Party::getEventStats() for the device's event - the same shape as the stats block on GET /api/v2/events/{id}.", + * @OA\Property(property="co2_powered", type="number"), + * @OA\Property(property="co2_unpowered", type="number"), + * @OA\Property(property="co2_total", type="number"), + * @OA\Property(property="waste_powered", type="number"), + * @OA\Property(property="waste_unpowered", type="number"), + * @OA\Property(property="waste_total", type="number"), + * @OA\Property(property="fixed_devices", type="number"), + * @OA\Property(property="fixed_powered", type="number"), + * @OA\Property(property="fixed_unpowered", type="number"), + * @OA\Property(property="repairable_devices", type="number"), + * @OA\Property(property="dead_devices", type="number"), + * @OA\Property(property="unknown_repair_status", type="number"), + * @OA\Property(property="devices_powered", type="number"), + * @OA\Property(property="devices_unpowered", type="number"), + * @OA\Property(property="no_weight_powered", type="number"), + * @OA\Property(property="no_weight_unpowered", type="number"), + * @OA\Property(property="participants", type="number"), + * @OA\Property(property="volunteers", type="number"), + * @OA\Property(property="hours_volunteered", type="number"), + * @OA\Property(property="invited", type="number") * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function updateDevicev2(Request $request, $iddevices): JsonResponse @@ -394,6 +430,17 @@ public function updateDevicev2(Request $request, $iddevices): JsonResponse ]; $device = Device::findOrFail($iddevices); + + // IDOR guard: the permission check above validated the *target* eventid + // from the request body, but the device is loaded by its URL id. Also + // require edit permission on the device's *current* owning event - + // otherwise a host of event A could pass eventid=A and reassign/ + // overwrite a device that actually belongs to someone else's event B + // (deleteDevicev2 derives the event from the device for the same reason). + if (!Fixometer::userHasEditEventsDevicesPermission($device->event, $user->id)) { + abort(403); + } + $device->update($data); event(new DeviceCreatedOrUpdated($device)); @@ -420,7 +467,8 @@ public function updateDevicev2(Request $request, $iddevices): JsonResponse * operationId="deleteDevice", * tags={"Devices"}, * summary="Delete Device", - * description="Deletes a device.", + * description="Deletes a device. Requires edit-events-devices permission on the device's owning event.", + * security={{"apiToken":{}}}, * @OA\Parameter( * name="id", * description="Device id", @@ -432,12 +480,36 @@ public function updateDevicev2(Request $request, $iddevices): JsonResponse * ), * @OA\Response( * response=200, - * description="Successful operation", + * description="Successful operation. Returns the owning event's stats, to save the client another API call to update its store.", + * @OA\JsonContent( + * @OA\Property(property="id", type="string", description="The id of the deleted device"), + * @OA\Property(property="stats", type="object", description="Party::getEventStats() for the device's (now former) event - the same shape as the stats block on GET /api/v2/events/{id}.", + * @OA\Property(property="co2_powered", type="number"), + * @OA\Property(property="co2_unpowered", type="number"), + * @OA\Property(property="co2_total", type="number"), + * @OA\Property(property="waste_powered", type="number"), + * @OA\Property(property="waste_unpowered", type="number"), + * @OA\Property(property="waste_total", type="number"), + * @OA\Property(property="fixed_devices", type="number"), + * @OA\Property(property="fixed_powered", type="number"), + * @OA\Property(property="fixed_unpowered", type="number"), + * @OA\Property(property="repairable_devices", type="number"), + * @OA\Property(property="dead_devices", type="number"), + * @OA\Property(property="unknown_repair_status", type="number"), + * @OA\Property(property="devices_powered", type="number"), + * @OA\Property(property="devices_unpowered", type="number"), + * @OA\Property(property="no_weight_powered", type="number"), + * @OA\Property(property="no_weight_unpowered", type="number"), + * @OA\Property(property="participants", type="number"), + * @OA\Property(property="volunteers", type="number"), + * @OA\Property(property="hours_volunteered", type="number"), + * @OA\Property(property="invited", type="number") + * ) + * ) * ), - * @OA\Response( - * response=404, - * description="Device not found", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -594,22 +666,349 @@ private function validateDeviceParams(Request $request, $create): array ]; } - private function getUser() + + /** + * @OA\Post( + * path="/api/v2/devices/{id}/images", + * operationId="uploadDeviceImagev2", + * tags={"Devices"}, + * summary="Attach a completed tus upload as a device photo", + * description="Mirrors GroupMembershipController::uploadImagev2 and EventAttendanceController::uploadImagev2 (design §5 point 11: extend PR #868's tus pattern to device images too) - upload the file to /api/tus first, then attach it here by upload_key. Devices support multiple photos, so an upload never clears previous ones. Permission: userHasEditEventsDevicesPermission for the device's event.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(required={"upload_key"}, @OA\Property(property="upload_key", type="string")) + * ), + * @OA\Response( + * response=200, + * description="Image attached", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="image_url", type="string") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function uploadImagev2(Request $request, $iddevices): JsonResponse + { + $user = $request->user(); + $device = Device::findOrFail($iddevices); + + if (!Fixometer::userHasEditEventsDevicesPermission($device->event, $user->id)) { + abort(403); + } + + $validated = $request->validate([ + 'upload_key' => 'required|string', + ]); + + $filePath = EventAttendanceController::validatedTusFilePath($validated['upload_key'], 'devices'); + + $file = new \FixometerFile(); + // $clear=false: devices support multiple photos, unlike a group/profile picture. + $filename = $file->uploadLocalFile($filePath, 'image', $device->iddevices, env('TBL_DEVICES'), false, true, false); + + $cache = Tus::buildCache(); + $cache->delete($validated['upload_key']); + @unlink($filePath); + + if (! $filename) { + throw ValidationException::withMessages([ + 'upload_key' => [__('devices.image_upload_error')], + ]); + } + + return response()->json([ + 'data' => [ + 'image_url' => url('/uploads/mid_'.$filename), + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/devices/{id}/images/{idimages}", + * operationId="deleteDeviceImagev2", + * tags={"Devices"}, + * summary="Detach a photo from a device", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Parameter(name="idimages", description="The xref id linking the image to the device", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteImagev2(Request $request, $iddevices, $idimages): JsonResponse { - // We want to allow this call to work if a) we are logged in as a user, or b) we have a valid API token. - // - // This is a slightly odd thing to do, but it is necessary to get both the PHPUnit tests and the - // real client use of the API to work. - $user = Auth::user(); + $user = $request->user(); + $device = Device::findOrFail($iddevices); + + if (!Fixometer::userHasEditEventsDevicesPermission($device->event, $user->id)) { + abort(403); + } + + $xref = Xref::where('idxref', $idimages) + ->where('reference', $device->iddevices) + ->where('reference_type', env('TBL_DEVICES')) + ->first(); + + if (! $xref) { + abort(404, 'Image not found for this device.'); + } + + $xref->delete(); + + return response()->json(['data' => ['deleted' => true]]); + } + + /** + * @OA\Get( + * path="/api/v2/devices/options", + * operationId="getDeviceOptionsv2", + * tags={"Devices"}, + * summary="Hard-coded device option lists: barriers, spare parts, next steps", + * description="Item-type autocomplete/category suggestion is GET /api/v2/items and brands is GET /api/v2/brands (both already exist and are reused, not duplicated here). spare_parts/next_steps have no table - hard-coded the same way Device::REPAIR_STATUS_*_STR constants already are.", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="barriers", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="spare_parts", type="array", @OA\Items(type="string")), + * @OA\Property(property="next_steps", type="array", @OA\Items(type="string")) + * )) + * ) + * ) + */ + public function optionsv2(): JsonResponse + { + return response()->json([ + 'data' => [ + 'barriers' => Barrier::all()->map(fn ($b) => ['id' => $b->id, 'name' => $b->barrier])->values()->all(), + 'spare_parts' => [ + Device::PARTS_PROVIDER_NO_STR, + Device::PARTS_PROVIDER_MANUFACTURER_STR, + Device::PARTS_PROVIDER_THIRD_PARTY_STR, + ], + 'next_steps' => [ + Device::NEXT_STEPS_MORE_TIME_NEEDED_STR, + Device::NEXT_STEPS_PROFESSIONAL_HELP_STR, + Device::NEXT_STEPS_DO_IT_YOURSELF_STR, + ], + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/devices", + * operationId="listDevicesv2", + * tags={"Devices"}, + * summary="List/search devices, paginated", + * description="v2 equivalent of GET /api/devices/{page}/{size} (v1, kept for the legacy client). Read ApiController::getDevices() for the query builder this mirrors - same filters, same join/LIKE semantics, same batch-loaded images.", + * @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", default=1)), + * @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", default=20)), + * @OA\Parameter(name="sortBy", in="query", @OA\Schema(type="string", default="event_start_utc")), + * @OA\Parameter(name="sortDesc", in="query", @OA\Schema(type="string", default="DESC")), + * @OA\Parameter(name="powered", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="category", in="query", @OA\Schema(type="integer")), + * @OA\Parameter(name="brand", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="model", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="item_type", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="status", in="query", @OA\Schema(type="integer")), + * @OA\Parameter(name="comments", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="wiki", in="query", @OA\Schema(type="boolean")), + * @OA\Parameter(name="group", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="from_date", in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="to_date", in="query", @OA\Schema(type="string")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="count", type="integer"), + * @OA\Property(property="items", type="array", @OA\Items(ref="#/components/schemas/Device")) + * )) + * ) + * ) + */ + public function listDevicesv2(Request $request): JsonResponse + { + $page = max(1, (int) $request->input('page', 1)); + $size = max(1, (int) $request->input('size', 20)); + + // Whitelist the sortable columns. The value comes from the client's + // clickable table headers (DevicesSearchTable.vue), so it must NOT be + // fed raw into orderBy(): the column argument is interpolated into the + // query grammar, so an un-whitelisted string is a SQL-injection vector. + // Keys are the field names the client sends per column; values are the + // qualified columns on the joined query. Default (and fallback for any + // unknown key) is the repair-event date, matching the legacy default. + $sortColumns = [ + 'event_start_utc' => 'events.event_start_utc', + 'item_type' => 'devices.item_type', + 'category' => 'categories.name', + 'brand' => 'devices.brand', + 'groupname' => 'groups.name', + 'repair_status' => 'devices.repair_status', + 'created_at' => 'devices.created_at', + ]; + $sortKey = $request->input('sortBy'); + // is_string guards against a non-scalar sortBy (e.g. ?sortBy[]=x), which + // would otherwise throw on the array-key lookup rather than fall back. + $sortColumn = (is_string($sortKey) && isset($sortColumns[$sortKey])) ? $sortColumns[$sortKey] : 'events.event_start_utc'; + $sortDir = strtolower((string) $request->input('sortDesc', 'DESC')) === 'asc' ? 'asc' : 'desc'; + $powered = $request->input('powered'); + $category = $request->input('category'); + $brand = $request->input('brand'); + $model = $request->input('model'); + $item_type = $request->input('item_type'); + $status = $request->input('status'); + $comments = $request->input('comments'); + $wiki = filter_var($request->input('wiki', false), FILTER_VALIDATE_BOOLEAN); + $group = $request->input('group'); + $from_date = $request->input('from_date'); + $to_date = $request->input('to_date'); + + // Same filter set as v1 ApiController::getDevices(), including its "powered defaults to + // unpowered when the param is absent" quirk - preserved for parity, not a new decision. + $wheres = [ + ['categories.powered', '=', $powered == 'true' ? 1 : 0], + ]; + + if ($category) { + $wheres[] = ['idcategories', '=', $category]; + } - if (!$user) { - $user = auth('api')->user(); + if ($brand) { + $wheres[] = ['devices.brand', 'LIKE', '%'.$brand.'%']; } - if (!$user) { - throw new AuthenticationException(); + if ($model) { + $wheres[] = ['devices.model', 'LIKE', '%'.$model.'%']; } - return $user; + if ($item_type) { + $wheres[] = ['devices.item_type', 'LIKE', '%'.$item_type.'%']; + } + + if ($comments) { + $wheres[] = ['devices.problem', 'LIKE', '%'.$comments.'%']; + } + + if ($wiki) { + $wheres[] = ['devices.wiki', '=', 1]; + } + + if ($status) { + $wheres[] = ['repair_status', '=', $status]; + } + + if ($group) { + $wheres[] = ['groups.name', 'LIKE', '%'.$group.'%']; + } + + if ($from_date) { + $wheres[] = ['events.event_start_utc', '>=', $from_date]; + } + + if ($to_date) { + $wheres[] = ['events.event_end_utc', '<=', $to_date]; + } + + // `groups` is joined ONLY when something needs it: the group-name + // filter, or a sort on a groups column. It used to be joined + // unconditionally, so every request paid for joining the whole devices + // table to groups even when no filter or sort referenced it - in both + // the COUNT and the fetch. Group data for the returned rows comes from + // the `deviceEvent.theGroup` eager load, not this join, so dropping it + // changes no output. + $needsGroupsJoin = $request->filled('group') || str_starts_with($sortColumn, 'groups.'); + + $query = Device::with(['deviceEvent.theGroup', 'deviceCategory', 'barriers']) + ->join('events', 'events.idevents', '=', 'devices.event') + ->when($needsGroupsJoin, fn ($q) => $q->join('groups', 'events.group', '=', 'groups.idgroups')) + ->join('categories', 'devices.category', '=', 'categories.idcategories') + ->where($wheres) + ->orderBy($sortColumn, $sortDir); + + // Count without the ORDER BY: it cannot change the total, and it stops + // MySQL sorting the whole joined set just to count it. + $count = (clone $query)->reorder()->count(); + + $items = $query->skip(($page - 1) * $size) + ->take($size) + ->get(); + + // Batch-load device images to avoid N+1 per device. + $device_ids = $items->pluck('iddevices')->toArray(); + $allImages = (new \FixometerFile)->findImagesForMany(env('TBL_DEVICES'), $device_ids); + foreach ($items as $item) { + $item->preloadedImages = $allImages[$item->iddevices] ?? []; + } + + $item_data = []; + foreach ($items as $item) { + $item_data[] = (new \App\Http\Resources\Device($item))->resolve(); + } + + return response()->json([ + 'data' => [ + 'count' => $count, + 'items' => $item_data, + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/stats/latest-repaired-event", + * operationId="getLatestRepairedEventv2", + * tags={"Devices"}, + * summary="Most recent finished event with at least one repaired device", + * description="Public. Mirrors the inline query in the legacy DeviceController::index() (fixometer home page banner) - Party::with('theGroup')->hasDevicesRepaired(1)->eventHasFinished()->orderBy('event_start_utc','DESC')->first().", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", nullable=true, + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="waste_prevented", type="number"), + * @OA\Property(property="group", ref="#/components/schemas/GroupSummary") + * )) + * ) + * ) + */ + public function latestRepairedEventv2(): JsonResponse + { + $event = Party::with('theGroup') + ->hasDevicesRepaired(1) + ->eventHasFinished() + ->orderBy('event_start_utc', 'DESC') + ->first(); + + if (! $event) { + return response()->json(['data' => null]); + } + + return response()->json([ + 'data' => [ + 'id' => $event->idevents, + 'waste_prevented' => $event->waste_prevented, + 'group' => \App\Http\Resources\GroupSummary::make($event->theGroup), + ], + ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/API/DiscourseController.php b/app/Http/Controllers/API/DiscourseController.php index 8935171367..3c3be1be7b 100644 --- a/app/Http/Controllers/API/DiscourseController.php +++ b/app/Http/Controllers/API/DiscourseController.php @@ -14,6 +14,52 @@ class DiscourseController extends Controller { /** * Get top Talk topics. + * + * @OA\Get( + * path="/api/talk/topics/{tag}", + * operationId="getDiscussionTopics", + * tags={"Discourse"}, + * summary="Get top Restarters Talk (Discourse) topics", + * description="Public - doesn't need authentication. Used by the Nuxt dashboard's 'What's happening' panel. Returns [] if the restarters.features.discourse_integration feature flag is off, or if the call to Discourse fails (errors are logged, not thrown). Results are cached for 60 seconds per tag under the key discourse_topics[_{tag}].", + * @OA\Parameter( + * name="tag", + * description="Optional Discourse tag slug to filter topics by. Omit for the site-wide latest topics.", + * required=false, + * in="path", + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="success", type="string", example="success"), + * @OA\Property( + * property="topics", + * type="array", + * description="Raw Discourse topic objects (passed through from Discourse's /latest.json or /tag/{tag}/l/latest.json), each enriched with an embedded 'category' object matched from Discourse's /site.json. Empty if the discourse_integration feature is disabled or the upstream call fails.", + * @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="title", type="string"), + * @OA\Property(property="slug", type="string"), + * @OA\Property(property="posts_count", type="integer"), + * @OA\Property(property="reply_count", type="integer"), + * @OA\Property(property="created_at", type="string", format="date-time"), + * @OA\Property(property="last_posted_at", type="string", format="date-time", nullable=true), + * @OA\Property(property="category_id", type="integer"), + * @OA\Property( + * property="category", + * type="object", + * description="Merged in from Discourse's /site.json where category.id == topic.category_id", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="slug", type="string"), + * @OA\Property(property="color", type="string") + * ) + * ) + * ) + * ) + * ) + * ) */ public function discussionTopics(Request $request, DiscourseService $discourseService, string $tag = NULL): JsonResponse { diff --git a/app/Http/Controllers/API/EventAttendanceController.php b/app/Http/Controllers/API/EventAttendanceController.php new file mode 100644 index 0000000000..a0bc11fe3f --- /dev/null +++ b/app/Http/Controllers/API/EventAttendanceController.php @@ -0,0 +1,611 @@ +user(); + $event = Party::findOrFail($idevents); + + $alreadyAttending = EventsUsers::where('event', $idevents) + ->where('user', $user->id) + ->where('status', '1') + ->exists(); + + $userEvent = EventsUsers::updateOrCreate([ + 'user' => $user->id, + 'event' => $idevents, + ], [ + 'status' => '1', + 'role' => Role::RESTARTER, + ]); + + $promptFollowGroup = ! $user->isInGroup($event->theGroup->idgroups); + + if (! $alreadyAttending) { + self::notifyHostsOfRsvp($userEvent, $idevents); + } + + return response()->json([ + 'data' => [ + 'attending' => true, + 'already_attending' => $alreadyAttending, + 'prompt_follow_group' => $promptFollowGroup, + ], + ]); + } + + /** + * Mirrors PartyController::notifyHostsOfRsvp. + */ + private static function notifyHostsOfRsvp(EventsUsers $userEvent, $eventId): void + { + $hosts = User::join('events_users', 'events_users.user', '=', 'users.id') + ->where('events_users.event', $eventId) + ->where('events_users.role', Role::HOST) + ->select('users.*') + ->get(); + + if ($hosts->count()) { + $rsvpUser = User::find($userEvent->user); + $event = Party::find($eventId); + + Notification::send($hosts, new RSVPEvent([ + 'user_name' => $rsvpUser->name, + 'event_venue' => $event->venue, + 'event_url' => url('/party/view/'.$eventId), + ])); + } + } + + /** + * @OA\Delete( + * path="/api/v2/events/{id}/attendees/me", + * operationId="cancelRsvpEventv2", + * tags={"Events"}, + * summary="Cancel RSVP / decline an invite to an event as the current user", + * description="Replaces GET /party/cancel-invite/{id}. Idempotent: cancelling when not attending/invited still returns success. Loop-deletes rather than a bulk delete, to keep model observers firing (mirrors PartyController::cancelInvite).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Left", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="left", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) + */ + public function cancelRsvpv2(Request $request, $idevents): JsonResponse + { + $user = $request->user(); + + foreach (EventsUsers::where('user', $user->id)->where('event', $idevents)->get() as $row) { + $row->delete(); + } + + return response()->json(['data' => ['left' => true]]); + } + + /** + * @OA\Patch( + * path="/api/v2/events/{id}/volunteers/{iduser}", + * operationId="patchEventVolunteerv2", + * tags={"Events","Volunteers"}, + * summary="Set whether a volunteer is a host of the event", + * description="Sets EventsUsers.role to HOST/RESTARTER for that user's row(s) on this event. Requires host/network-coordinator/administrator permission (userHasEditPartyPermission, which already covers all three).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Parameter(name="iduser", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody(required=true, @OA\JsonContent(required={"host"}, @OA\Property(property="host", type="boolean"))), + * @OA\Response( + * response=200, + * description="Updated", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="host", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function patchVolunteerv2(Request $request, $idevents, $iduser): JsonResponse + { + $user = $request->user(); + Party::findOrFail($idevents); + User::findOrFail($iduser); + + if (! Fixometer::userHasEditPartyPermission($idevents, $user->id)) { + abort(403); + } + + $request->validate(['host' => 'required|boolean']); + $host = $request->boolean('host'); + + foreach (EventsUsers::where('event', $idevents)->where('user', $iduser)->get() as $row) { + $row->role = $host ? Role::HOST : Role::RESTARTER; + $row->save(); + } + + return response()->json(['data' => ['host' => $host]]); + } + + /** + * @OA\Post( + * path="/api/v2/events/{id}/request-review", + * operationId="requestEventReview", + * tags={"Events"}, + * summary="Ask attendees to review the event's repairs", + * description="Sends the EventRepairs notification to every confirmed restarter who attended, asking them to review/contribute to the repair records. Requires host/coordinator/administrator permission (userHasEditPartyPermission).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Event id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response(response=200, description="Requests sent", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="requested", type="integer")))), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * ) + * + * Port of PartyController::getContributions (the old "Request review" modal + * on the event page linking to GET /party/contribution/{id}). + */ + public function requestReviewv2(Request $request, $idevents): JsonResponse + { + $user = $request->user(); + $event = Party::findOrFail($idevents); + + if (! Fixometer::userHasEditPartyPermission($idevents, $user->id)) { + abort(403); + } + + // Confirmed restarters (status 1, role RESTARTER) who attended. + $restarters = User::join('events_users', 'events_users.user', '=', 'users.id') + ->where('events_users.status', 1) + ->where('events_users.role', Role::RESTARTER) + ->where('events_users.event', $idevents) + ->select('users.*') + ->get(); + + Notification::send($restarters, new EventRepairs([ + 'event_name' => $event->getEventName(), + 'event_url' => rtrim(config('restarters.frontend_url'), '/').'/party/view/'.intval($idevents).'#devices', + ])); + + return response()->json(['data' => ['requested' => $restarters->count()]]); + } + + /** + * @OA\Delete( + * path="/api/v2/events/{id}/volunteers/{idevents_users}", + * operationId="deleteEventVolunteerv2", + * tags={"Events","Volunteers"}, + * summary="Remove a volunteer from an event", + * description="Keyed by the events_users row id, NOT the user id (judgment call #2 in api-contracts-phase-c.md): a manually-added volunteer may have no associated user, so the row id is the only stable key. Mirrors POST /party/remove-volunteer. Idempotent: removing an already-removed/unknown row still returns success.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Parameter(name="idevents_users", description="The events_users row id (Attendee.id from GET .../attendees)", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteVolunteerv2(Request $request, $idevents, $idevents_users): JsonResponse + { + $user = $request->user(); + Party::findOrFail($idevents); + + if (! Fixometer::userHasEditPartyPermission($idevents, $user->id)) { + abort(403); + } + + $volunteer = EventsUsers::where('idevents_users', $idevents_users)->where('event', $idevents)->first(); + $deleted = false; + + if ($volunteer) { + $volunteer->delete(); + $deleted = true; + } + + return response()->json(['data' => ['deleted' => $deleted]]); + } + + /** + * @OA\Post( + * path="/api/v2/events/{id}/invites", + * operationId="inviteToEventv2", + * tags={"Events"}, + * summary="Invite people to an event by email", + * description="Requires administrator, network-coordinator-for-group, or host-of-group permission. Mirrors PartyController@postSendInvite and B2's POST /api/v2/groups/{id}/invites. Known emails get an in-app invite (hash-status row + JoinEvent notification); unknown emails get an Invite row (type 'event') + registration email; already-confirmed users are silently skipped; only malformed addresses are reported as invalid.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"emails"}, + * @OA\Property(property="emails", type="array", @OA\Items(type="string")), + * @OA\Property(property="message", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Invites processed", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="invites_sent", type="integer"), + * @OA\Property(property="invalid", type="array", @OA\Items(type="string")) + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function invitesv2(Request $request, $idevents): JsonResponse + { + $user = $request->user(); + $event = Party::findOrFail($idevents); + + if (! Fixometer::userHasEditPartyPermission($idevents, $user->id)) { + abort(403); + } + + $request->validate([ + 'emails' => 'required|array|min:1', + 'emails.*' => 'string', + 'message' => 'nullable|string', + ]); + + $message = $request->input('message'); + $groupName = $event->theGroup->name; + + $valid = []; + $invalid = []; + + foreach ($request->input('emails') as $email) { + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $valid[] = $email; + } else { + $invalid[] = $email; + } + } + + $existingUsers = User::whereIn('email', $valid)->get(); + $nonUserEmails = array_values(array_diff($valid, $existingUsers->pluck('email')->all())); + + foreach ($existingUsers as $existingUser) { + $userEvent = EventsUsers::where('user', $existingUser->id)->where('event', $idevents)->first(); + + // Already confirmed - nothing to do. + if ($userEvent && (string) $userEvent->status === '1') { + continue; + } + + $hash = Fixometer::generateHash(); + $url = url('/party/accept-invite/'.$idevents.'/'.$hash); + + if ($userEvent) { + $userEvent->update(['status' => $hash]); + } else { + EventsUsers::create([ + 'user' => $existingUser->id, + 'event' => $idevents, + 'status' => $hash, + 'role' => Role::RESTARTER, + ]); + } + + Notification::send($existingUser, new JoinEvent([ + 'name' => $user->name, + 'group' => $groupName, + 'url' => $url, + 'view_url' => url('/party/view/'.$idevents), + 'message' => $message, + 'event' => $event, + ], $existingUser)); + } + + foreach ($nonUserEmails as $nonUserEmail) { + $hash = Fixometer::generateHash(); + + $invite = Invite::create([ + 'record_id' => $idevents, + 'email' => $nonUserEmail, + 'hash' => $hash, + 'type' => 'event', + ]); + + Notification::send($invite, new JoinEvent([ + 'name' => $user->name, + 'group' => $groupName, + 'url' => url('/user/register/'.$hash), + 'view_url' => url('/party/view/'.$idevents), + 'message' => $message, + 'event' => $event, + ])); + } + + return response()->json([ + 'data' => [ + 'invites_sent' => count($valid), + 'invalid' => $invalid, + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/events/{id}/images", + * operationId="uploadEventImagev2", + * tags={"Events"}, + * summary="Attach a completed tus upload as an event photo", + * description="Mirrors GroupMembershipController::uploadImagev2 - upload the file to /api/tus first, then attach it here by upload_key. Unlike group/profile images, event photos are a gallery (multiple per event, mirroring the legacy multi-file imageUpload()) so an upload never clears previous photos. Permission: any attendee (any EventsUsers row, any status) or Administrator - mirrors PartyController::deleteImage, looser than edit-party since any attendee can add repair photos.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(required={"upload_key"}, @OA\Property(property="upload_key", type="string")) + * ), + * @OA\Response( + * response=200, + * description="Image attached", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="image_url", type="string") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function uploadImagev2(Request $request, $idevents): JsonResponse + { + $user = $request->user(); + $event = Party::findOrFail($idevents); + + self::authorizeEventImageEdit($user, $idevents); + + $validated = $request->validate([ + 'upload_key' => 'required|string', + ]); + + $filePath = self::validatedTusFilePath($validated['upload_key'], 'events'); + + $file = new \FixometerFile(); + // $clear=false: event photos are a gallery, not a single image like a group/profile photo. + $filename = $file->uploadLocalFile($filePath, 'image', $event->idevents, env('TBL_EVENTS'), false, true, false); + + $cache = Tus::buildCache(); + $cache->delete($validated['upload_key']); + @unlink($filePath); + + if (! $filename) { + throw ValidationException::withMessages([ + 'upload_key' => [__('events.image_upload_error')], + ]); + } + + // Restore the moderation-notification hook the Blade PartyController + // fired (the SendAdminModerateEventPhotosNotification listener is still + // registered, and throttles per-admin, so firing per upload is safe). + event(new EventImagesUploaded($event, $user->id)); + + return response()->json([ + 'data' => [ + 'image_url' => url('/uploads/mid_'.$filename), + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/events/{id}/images/{idimages}", + * operationId="deleteEventImagev2", + * tags={"Events"}, + * summary="Detach a photo from an event", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Parameter(name="idimages", description="The xref id linking the image to the event", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteImagev2(Request $request, $idevents, $idimages): JsonResponse + { + $user = $request->user(); + $event = Party::findOrFail($idevents); + + self::authorizeEventImageEdit($user, $idevents); + + $xref = \App\Xref::where('idxref', $idimages) + ->where('reference', $event->idevents) + ->where('reference_type', env('TBL_EVENTS')) + ->first(); + + if (! $xref) { + abort(404, 'Image not found for this event.'); + } + + $xref->delete(); + + return response()->json(['data' => ['deleted' => true]]); + } + + /** + * Shared "can edit this event's photos" gate for uploadImagev2/deleteImagev2: mirrors + * PartyController::deleteImage - any attendee (any EventsUsers row, regardless of status, + * so a pending invitee can still add photos) or an Administrator. + */ + private static function authorizeEventImageEdit(User $user, $idevents): void + { + $isAdministrator = Fixometer::hasRole($user, 'Administrator'); + $isAttendee = EventsUsers::where('event', $idevents)->where('user', $user->id)->exists(); + + if (! $isAdministrator && ! $isAttendee) { + abort(403); + } + } + + /** + * Shared tus-upload validation for event/device image endpoints: confirms the upload_key + * resolves to a completed (offset===size), <=2MB, jpeg/png/gif upload. Returns the local + * file path on success; throws a 422 ValidationException (and cleans up the cache/file) + * otherwise. Mirrors GroupMembershipController::uploadImagev2's inline validation. + */ + public static function validatedTusFilePath(string $uploadKey, string $translationFile): string + { + $cache = Tus::buildCache(); + $meta = $cache->get($uploadKey); + $filePath = $meta['file_path'] ?? null; + + if (! $meta || ! $filePath || ! is_file($filePath)) { + throw ValidationException::withMessages([ + 'upload_key' => [__($translationFile.'.image_upload_error')], + ]); + } + + if (($meta['offset'] ?? null) !== ($meta['size'] ?? null)) { + throw ValidationException::withMessages([ + 'upload_key' => [__($translationFile.'.image_upload_error')], + ]); + } + + if (filesize($filePath) > 2 * 1024 * 1024) { + $cache->delete($uploadKey); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__($translationFile.'.image_upload_error')], + ]); + } + + $mime = @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $filePath); + + if (! in_array($mime, ['image/jpeg', 'image/png', 'image/gif'], true)) { + $cache->delete($uploadKey); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__($translationFile.'.image_upload_error')], + ]); + } + + return $filePath; + } + + /** + * @OA\Delete( + * path="/api/v2/events/{id}", + * operationId="deleteEventv2", + * tags={"Events"}, + * summary="Delete an event", + * description="Permission = userHasEditPartyPermission || userIsHostOfGroup, matching PartyController::deleteEvent. Deletes audits, hard-deletes the event's devices, loop-deletes EventsUsers (for observers), soft-deletes the event, fires EventDeleted. Party::canDelete() (zero devices) is only a client-side confirm-dialog rule today - this endpoint does NOT enforce it, matching current behaviour (judgment call #4 in api-contracts-phase-c.md).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteEventv2(Request $request, $idevents): JsonResponse + { + $user = $request->user(); + $event = Party::findOrFail($idevents); + + if (! Fixometer::userHasEditPartyPermission($idevents, $user->id) && ! Fixometer::userIsHostOfGroup($event->group, $user->id)) { + abort(403); + } + + Audits::where('auditable_type', Party::class)->where('auditable_id', $idevents)->delete(); + Device::where('event', $idevents)->delete(); + + // Loop-delete to avoid the gotcha where bulk delete operations don't invoke observers. + foreach (EventsUsers::where('event', $idevents)->get() as $row) { + $row->delete(); + } + + $event->delete(); + + event(new EventDeleted($event)); + + return response()->json(['data' => ['deleted' => true]]); + } +} diff --git a/app/Http/Controllers/API/EventController.php b/app/Http/Controllers/API/EventController.php index 93907489b5..eab1ad702a 100644 --- a/app/Http/Controllers/API/EventController.php +++ b/app/Http/Controllers/API/EventController.php @@ -25,6 +25,20 @@ class EventController extends Controller { + /** + * @OA\Get( + * path="/api/events/network/{date_from}/{date_to}", + * operationId="getEventsByUsersNetworksLegacy", + * tags={"Legacy", "Events"}, + * summary="Events across the authenticated user's networks (legacy, used by Repair Together)", + * description="Legacy endpoint outside the /api/v2 surface, used by the Repair Together integration. Returns approved events for every group in every network the caller coordinates, each with per-event impact stats and widget URLs. Authenticated via the ?api_token= query string.", + * security={{"ApiKeyAuth":{}}}, + * @OA\Parameter(name="date_from", in="path", required=false, description="Optional ISO-8601 lower bound on event start.", @OA\Schema(type="string", format="date-time")), + * @OA\Parameter(name="date_to", in="path", required=false, description="Optional ISO-8601 upper bound on event end.", @OA\Schema(type="string", format="date-time")), + * @OA\Response(response=200, description="A list of events with per-event impact stats and widget URLs.", @OA\JsonContent(type="array", @OA\Items(type="object"))), + * @OA\Response(response=404, description="No events found for the caller's networks.") + * ) + */ public function getEventsByUsersNetworks(Request $request, $date_from = null, $date_to = null, $timezone = 'UTC') { $authenticatedUser = Auth::user(); @@ -146,6 +160,28 @@ public function getEventsByUsersNetworks(Request $request, $date_from = null, $d return $collection; } + /** + * @OA\Put( + * path="/api/events/{id}/volunteers", + * operationId="addVolunteerLegacy", + * tags={"Legacy", "Events"}, + * summary="Add a volunteer to an event (legacy)", + * description="Legacy endpoint outside the /api/v2 surface. Adds a registered or unregistered volunteer to an event; requires edit-party permission. When volunteer_email_address is supplied for a non-existent user, an invitation email is sent.", + * security={{"ApiKeyAuth":{}}}, + * @OA\Parameter(name="id", in="path", required=true, description="Event id.", @OA\Schema(type="integer")), + * @OA\RequestBody( + * @OA\MediaType(mediaType="application/json", @OA\Schema( + * @OA\Property(property="user", description="Existing user id, or 'not-registered'.", type="string", nullable=true), + * @OA\Property(property="volunteer_email_address", type="string", format="email", nullable=true), + * @OA\Property(property="full_name", type="string", nullable=true) + * )) + * ), + * @OA\Response(response=200, description="Volunteer added.", @OA\JsonContent(@OA\Property(property="success", type="string", example="success"))), + * @OA\Response(response=403, description="Caller lacks edit-party permission."), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ public function addVolunteer(Request $request, $idevents): JsonResponse { $request->validate([ @@ -210,7 +246,7 @@ public function addVolunteer(Request $request, $idevents): JsonResponse // Send email. $from = User::find(Auth::user()->id); - $hash = substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24); + $hash = Fixometer::generateHash(); $url = url('/user/register/'.$hash); $invite = Invite::create([ @@ -237,12 +273,26 @@ public function addVolunteer(Request $request, $idevents): JsonResponse } + /** + * @OA\Get( + * path="/api/events/{id}/volunteers", + * operationId="listVolunteersLegacy", + * tags={"Legacy", "Events"}, + * summary="List an event's confirmed volunteers (legacy)", + * description="Legacy endpoint outside the /api/v2 surface (the v2 replacement is GET /api/v2/events/{id}/attendees). Returns the event's confirmed volunteers.", + * security={{"ApiKeyAuth":{}}}, + * @OA\Parameter(name="id", in="path", required=true, description="Event id.", @OA\Schema(type="integer")), + * @OA\Response(response=200, description="The event's confirmed volunteers.", @OA\JsonContent(type="array", @OA\Items(type="object"))), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ public function listVolunteers(Request $request, $idevents): JsonResponse { $party = Party::findOrFail($idevents); - // Get the user that the API has been authenticated as. - $user = auth('api')->user(); + // Get the user that the API has been authenticated as (whichever guard + // the auth:sanctum,api middleware resolved). + $user = $request->user(); // Only show emails to users who have edit permission on this event. $showEmails = $user && Fixometer::userHasEditPartyPermission($idevents, $user->id); @@ -281,10 +331,7 @@ public function listVolunteers(Request $request, $idevents): JsonResponse * ) * ) * ), - * @OA\Response( - * response=404, - * description="Event not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -292,26 +339,166 @@ public function getEventv2(Request $request, $idevents) { $party = Party::findOrFail($idevents); + // Events on unapproved (unmoderated) groups are only visible to the + // relevant hosts/coordinators/admins - restores the legacy + // PartyController::view() gate the API-only cutover dropped. Events on + // approved groups stay fully public. + if (! Fixometer::userHasViewPartyPermission($idevents, $this->optionalUser()?->id, $party)) { + abort(404); + } + return \App\Http\Resources\Party::make($party); } - private function getUser() + + /** + * Resolve the acting user across all guards (web session, SPA sanctum + * bearer, api_token) without throwing - for optional-auth endpoints that + * are public but behave differently for a recognised user. Plain + * $request->user() only checks the default 'web' guard, so it misses the + * client's bearer token on routes that carry no auth middleware. + */ + private function optionalUser() + { + return Auth::user() ?? auth('sanctum')->user() ?? auth('api')->user(); + } + + /** + * @OA\Get( + * path="/api/v2/events/{id}/attendees", + * operationId="getEventAttendeesv2", + * tags={"Events","Volunteers"}, + * summary="Get event attendees", + * description="Confirmed attendees (participants/volunteers/hosts) and pending invitees for an event. Replaces the Blade-only attended/invited/hosts computation in PartyController::view() and extends v1 GET /api/events/{id}/volunteers (confirmed-only). Unlike the Blade view, lists are NOT truncated.", + * @OA\Parameter( + * name="id", + * description="Event id", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="confirmed", type="array", @OA\Items( + * @OA\Property(property="id", type="integer", description="events_users.idevents_users"), + * @OA\Property(property="user", type="integer", nullable=true, description="Null for a manually-added, unregistered volunteer"), + * @OA\Property(property="fullName", type="string"), + * @OA\Property(property="role", type="integer", description="Role: HOST=3, RESTARTER=4, GUEST=5 (guest = plain attendee/participant)"), + * @OA\Property(property="confirmed", type="boolean"), + * @OA\Property(property="profilePath", type="string"), + * @OA\Property(property="volunteer", type="object", nullable=true, description="Present only when user is set", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string", nullable=true, description="Only when the caller has edit-party permission"), + * @OA\Property(property="user_skills", type="array", @OA\Items(type="object")) + * ) + * )), + * @OA\Property(property="invited", type="array", description="Same shape as confirmed, with confirmed:false; the raw status hash is not surfaced", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="user", type="integer", nullable=true), + * @OA\Property(property="fullName", type="string"), + * @OA\Property(property="role", type="integer"), + * @OA\Property(property="confirmed", type="boolean"), + * @OA\Property(property="profilePath", type="string"), + * @OA\Property(property="volunteer", type="object", nullable=true, + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string", nullable=true), + * @OA\Property(property="user_skills", type="array", @OA\Items(type="object")) + * ) + * )) + * )) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function attendeesv2(Request $request, $idevents): JsonResponse { - // We want to allow this call to work if a) we are logged in as a user, or b) we have a valid API token. - // - // This is a slightly odd thing to do, but it is necessary to get both the PHPUnit tests and the - // real client use of the API to work. - $user = Auth::user(); - - if (!$user) { - $user = auth('api')->user(); + $party = Party::findOrFail($idevents); + + // Optional auth: showEmails mirrors listVolunteers' gate, everything else is public. + // Resolve across guards so the SPA's bearer token is recognised on this + // auth-middleware-free route (default 'web' guard alone would miss it). + $user = $this->optionalUser(); + + // Events on unapproved groups are hidden from the public (legacy + // PartyController::view() gate) - approved-group events stay public. + if (! Fixometer::userHasViewPartyPermission($idevents, $user?->id, $party)) { + abort(404); } - if (!$user) { - throw new AuthenticationException(); + $showEmails = $user && Fixometer::userHasEditPartyPermission($idevents, $user->id); + + $confirmed = Party::expandVolunteers($party->allConfirmedVolunteers()->get(), $showEmails); + $invited = Party::expandVolunteers($party->allInvited()->get(), $showEmails); + + return response()->json([ + 'data' => [ + 'confirmed' => array_map(fn ($row) => self::shapeAttendee($row, true), $confirmed), + 'invited' => array_map(fn ($row) => self::shapeAttendee($row, false), $invited), + ], + ]); + } + + /** + * Shape an EventsUsers row (as expanded by Party::expandVolunteers()) into the + * confirmed/invited attendee JSON documented in api-contracts-phase-c.md#C1b. + */ + private static function shapeAttendee($row, bool $confirmed): array + { + $shaped = [ + 'id' => $row->idevents_users, + 'user' => $row->user, + 'fullName' => $row['fullName'], + 'role' => (int) $row->role, + 'confirmed' => $confirmed, + 'profilePath' => $row['profilePath'], + ]; + + if ($row->user) { + $shaped['volunteer'] = $row['volunteer']; + } + + return $shaped; + } + + /** + * @OA\Get( + * path="/api/v2/events/{id}/devices", + * operationId="getEventDevicesv2", + * tags={"Events","Devices"}, + * summary="Get the devices logged at an event", + * description="Replaces the Blade view() controller's inline device-resolve loop - not exposed as a callable endpoint today (Blade passes it as an initial prop). The Nuxt client needs it as a real call since there's no server render.", + * @OA\Parameter( + * name="id", + * description="Event id", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Device"))) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function devicesv2(Request $request, $idevents): JsonResponse + { + $party = Party::findOrFail($idevents); + + // Events on unapproved groups are hidden from the public (legacy + // PartyController::view() gate) - approved-group events stay public. + if (! Fixometer::userHasViewPartyPermission($idevents, $this->optionalUser()?->id, $party)) { + abort(404); } - return $user; + return response()->json([ + 'data' => \App\Http\Resources\Device::collection($party->devices()->get()), + ]); } /** @@ -320,30 +507,124 @@ private function getUser() * operationId="getEventsModeratev2", * tags={"Events"}, * summary="Get Events for Moderation", - * description="Only available for Administrators and Network Coordinators.", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Events requiring moderation across the networks the caller can moderate: Administrators see every network, Network Coordinators see their own networks. Returns an empty list for an authenticated user who is neither.", + * security={{"apiToken":{}}}, * @OA\Response( * response=200, * description="Successful operation", * @OA\JsonContent( * type="array", - * description="An array of groups", + * description="An array of events", * @OA\Items( * ref="#/components/schemas/Event" * ) * ) * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), * ) */ + /** + * @OA\Get( + * path="/api/v2/events/{id}/audits", + * operationId="getEventAuditsv2", + * tags={"Events"}, + * summary="Audit trail for an event", + * description="Backs the edit page's Event log tab. Administrator only, matching the legacy edit view's `$audits && hasRole(Administrator)` gate. Strings are rendered server-side from the event-audits lang files so the placeholder substitution stays in one place; each entry's `heading` and `changes` are HTML.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Audit entries, newest first", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="event", type="string", example="updated"), + * @OA\Property(property="heading", type="string"), + * @OA\Property(property="changes", type="array", @OA\Items(type="string")) + * ))) + * ), + * @OA\Response(response=403, description="Not an administrator"), + * @OA\Response(response=404, description="No such event") + * ) + */ + public function auditsv2($id): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $event = Party::find($id); + + if (! $event) { + return response()->json(['error' => 'No such event'], 404); + } + + // Rendered server-side, as the legacy view does + // (partials/log-accordion.blade.php's + // `@lang($type.'.'.$audit->event.'.metadata', $audit->getMetadata())`). + // Resolving them client-side would mean reimplementing the placeholder + // substitution and keeping two copies of the key layout in step. + $audits = $event->audits()->with('user')->orderBy('created_at', 'desc')->get(); + + return response()->json([ + 'data' => $audits->map(function ($audit) { + $changes = []; + + foreach ($audit->getModified() as $attribute => $modified) { + $key = 'event-audits.'.$audit->event.'.modified.'.$attribute; + $line = __($key, $modified); + + // An attribute with no lang entry returns the key itself - + // skip it rather than showing a raw key to the user, which + // is exactly the failure the /party moderation header had. + if ($line !== $key) { + $changes[] = $line; + } + } + + // SECURITY: audit_url is the full request URL, so for any write + // authenticated with ?api_token= it contains a VALID API TOKEN. + // laravel-auditing stores that verbatim, and the legacy view + // renders it to any Administrator opening the log. Strip the + // query string before rendering. NB this only stops the + // display. New rows no longer carry one either + // (App\Auditing\SanitisedUrlResolver), and `php artisan + // audits:scrub-urls` clears any written before that landed - + // this stays as defence in depth for environments that have + // not run it. + $metadata = $audit->getMetadata(); + + if (isset($metadata['audit_url']) && is_string($metadata['audit_url'])) { + $metadata['audit_url'] = strtok($metadata['audit_url'], '?'); + } + + $headingKey = 'event-audits.'.$audit->event.'.metadata'; + $heading = __($headingKey, $metadata); + + return [ + 'id' => $audit->id, + 'event' => $audit->event, + 'heading' => $heading === $headingKey ? null : $heading, + 'changes' => $changes, + ]; + })->values()->all(), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/moderate/events", + * operationId="moderateEventsv2", + * tags={"Events"}, + * summary="Events awaiting moderation", + * description="Events requiring moderation across every network the caller coordinates (all networks for an Administrator). Returns a bare array, not a {data:...} envelope. Deduped by id - an event whose group belongs to several networks would otherwise appear once per network.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Events awaiting moderation, soonest first", + * @OA\JsonContent(type="array", @OA\Items(type="object")) + * ) + * ) + */ public function moderateEventsv2(Request $request) { // Get the user that the API has been authenticated as. @@ -365,11 +646,25 @@ public function moderateEventsv2(Request $request) $events = array_merge($events, $network->eventsRequiringModeration()); } + // Dedupe by id: an event whose group belongs to more than one network + // is returned once per network by the loop above, so it appeared + // twice in the moderation queue. Observed against the parity fixtures, + // where the queue rendered the same pending event twice while develop + // showed it once. + $events = collect($events)->unique('idevents')->values()->all(); + usort($events, function ($a, $b) { return strtotime($a->event_start_utc) - strtotime($b->event_start_utc); }); - $ret = \App\Http\Resources\Party::collection(collect($events)); + // One query for every event's invited count, rather than one per event + // (see the Party resource's `invited` field). + // Eloquent's collection, not the base one - loadCount only exists on + // the former, and collect() returns the latter. + $collection = new \Illuminate\Database\Eloquent\Collection($events); + $collection->loadCount('allInvited'); + + $ret = \App\Http\Resources\Party::collection($collection); return response()->json($ret); } @@ -380,22 +675,13 @@ public function moderateEventsv2(Request $request) * operationId="createEvent", * tags={"Events"}, * summary="Create Event", - * description="Creates an event.", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Creates an event. `location` is required unless `online` is true.", + * security={{"apiToken":{}}}, * @OA\RequestBody( * @OA\MediaType( * mediaType="multipart/form-data", * @OA\Schema( - * required={"start","end","title","description","location","lat","lng"}, + * required={"groupid","start","end","title","description"}, * @OA\Property( * property="groupid", * title="id", @@ -448,12 +734,17 @@ public function moderateEventsv2(Request $request) * description="Successful operation", * @OA\JsonContent( * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Event" + * property="id", + * type="integer", + * description="Unique identifier of the newly-created event", + * example=1 * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function createEventv2(Request $request): JsonResponse @@ -561,22 +852,14 @@ public function createEventv2(Request $request): JsonResponse * operationId="editEvent", * tags={"Events"}, * summary="Edit Event", - * description="Edits an event. The event of a group cannot be changed after creation.", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Edits an event. The event of a group cannot be changed after creation. `location` is required unless `online` is true.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Event id", required=true, in="path", @OA\Schema(type="integer")), * @OA\RequestBody( * @OA\MediaType( * mediaType="multipart/form-data", * @OA\Schema( - * required={"start","end","title","description","location","lat","lng"}, + * required={"start","end","title","description"}, * @OA\Property( * property="start", * ref="#/components/schemas/Event/properties/start", @@ -609,6 +892,23 @@ public function createEventv2(Request $request): JsonResponse * property="link", * ref="#/components/schemas/Event/properties/link", * ), + * @OA\Property( + * description="Network-defined JSON data", + * property="network_data", + * @OA\Schema() + * ), + * @OA\Property( + * property="participants", + * description="New value for the participants headcount counter (replaces POST /party/update-quantity). Host/NC/admin gated, same as the rest of this endpoint.", + * type="integer", + * minimum=0, + * ), + * @OA\Property( + * property="volunteers", + * description="New value for the volunteers headcount counter (replaces POST /party/update-volunteerquantity). Host/NC/admin gated, same as the rest of this endpoint.", + * type="integer", + * minimum=0, + * ), * ) * ) * ), @@ -617,12 +917,17 @@ public function createEventv2(Request $request): JsonResponse * description="Successful operation", * @OA\JsonContent( * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Event" + * property="id", + * type="string", + * description="The event's id", + * example=1 * ) * ), - * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") * ) */ public function updateEventv2(Request $request, $idEvents): JsonResponse @@ -659,6 +964,26 @@ public function updateEventv2(Request $request, $idEvents): JsonResponse 'network_data' => $network_data, ]; + // Headcount counters (the +/- control next to "Participants"/"Volunteers"): folded in here + // instead of the legacy POST /party/update-quantity + update-volunteerquantity routes + // (judgment call #3 in api-contracts-phase-c.md). Those two routes had their own inline + // role check ((Host||NetworkCoordinator)&&userHasEditPartyPermission || Administrator) which + // is a SUBSET of the userHasEditPartyPermission check already enforced above (that helper + // already returns true for Administrator/NetworkCoordinator-for-network/host-of-group), so + // reusing it here only tightens, never loosens, who can bump the counters. + $request->validate([ + 'participants' => 'nullable|integer|min:0', + 'volunteers' => 'nullable|integer|min:0', + ]); + + if ($request->filled('participants')) { + $update['pax'] = $request->input('participants'); + } + + if ($request->filled('volunteers')) { + $update['volunteers'] = $request->input('volunteers'); + } + $party = Party::findOrFail($idEvents); $party->update($update); diff --git a/app/Http/Controllers/API/GroupController.php b/app/Http/Controllers/API/GroupController.php index c4b5abfc21..46d815e711 100644 --- a/app/Http/Controllers/API/GroupController.php +++ b/app/Http/Controllers/API/GroupController.php @@ -51,9 +51,17 @@ public static function getGroupChanges(Request $request) $groupAudits = self::getGroupAudits($dateFrom); + // Batched, not one Group::find() per audit row - same fix as + // UserGroupsController::changes. A caller asking for all changes (no + // dateFrom) otherwise pays one query per audit, which grows with the + // whole history rather than with the result set. + $groups = Group::whereIn('idgroups', $groupAudits->pluck('auditable_id')->unique()->all()) + ->get() + ->keyBy('idgroups'); + $groupChanges = []; foreach ($groupAudits as $groupAudit) { - $group = Group::find($groupAudit->auditable_id); + $group = $groups->get($groupAudit->auditable_id); if (! is_null($group) && $group->changesShouldPushToZapier()) { $groupChanges[] = self::mapDetailsAndAuditToChange($group, $groupAudit); } @@ -257,10 +265,17 @@ public static function getGroupList(): JsonResponse * type="object", * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="name", type="string", example="Group Name"), + * @OA\Property(property="lat", type="number", nullable=true, example=51.5), + * @OA\Property(property="lng", type="number", nullable=true, example=-0.12), + * @OA\Property(property="country", type="string", nullable=true, example="United Kingdom"), + * @OA\Property(property="network_ids", type="array", @OA\Items(type="integer")), + * @OA\Property(property="tag_ids", type="array", @OA\Items(type="integer")), + * @OA\Property(property="archived_at", type="string", format="date-time", nullable=true), * ) * ) * ) * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), * ) */ @@ -269,20 +284,40 @@ public static function listNamesv2(Request $request) { 'includeArchived' => ['string', 'in:true,false'], ]); - // We only return the group id and name, for speed. - $query = Group::select('idgroups', 'name', 'archived_at'); + // We only return a small number of attributes, for speed: this index + // drives the groups map (positions/tooltips) and the client-side + // name/country/network/tag filters, with full rows hydrated on demand + // via /groups/summary?ids=. + $query = Group::select('idgroups', 'name', 'latitude', 'longitude', 'country_code', 'archived_at'); if (!$request->has('includeArchived') || $request->get('includeArchived') == 'false') { $query = $query->whereNull('archived_at'); } $groups = $query->get(); + + // Two cheap lookups instead of per-group relation loads. + $networkIds = \DB::table('group_network') + ->whereIn('group_id', $groups->pluck('idgroups')) + ->get() + ->groupBy('group_id'); + $tagIds = \DB::table('grouptags_groups') + ->whereIn('group', $groups->pluck('idgroups')) + ->get() + ->groupBy('group'); + $ret = []; foreach ($groups as $group) { $ret[] = [ 'id' => $group->idgroups, 'name' => $group->name, + 'lat' => $group->latitude !== null ? (float) $group->latitude : null, + 'lng' => $group->longitude !== null ? (float) $group->longitude : null, + 'country' => \App\Helpers\Fixometer::getCountryFromCountryCode($group->country_code), + 'network_ids' => $networkIds->has($group->idgroups) ? $networkIds[$group->idgroups]->pluck('network_id')->map(fn ($id) => (int) $id)->all() : [], + // The pivot columns are varchars; the API contract is integers. + 'tag_ids' => $tagIds->has($group->idgroups) ? $tagIds[$group->idgroups]->pluck('group_tag')->map(fn ($id) => (int) $id)->all() : [], 'archived_at' => $group->archived_at ? Carbon::parse($group->archived_at)->toIso8601String() : null ]; } @@ -292,6 +327,100 @@ public static function listNamesv2(Request $request) { ]; } + /** + * @OA\Get( + * path="/api/v2/groups/summary", + * operationId="getGroupSummariesv2", + * tags={"Groups"}, + * summary="Get list of groups with summary information", + * @OA\Parameter( + * name="archived", + * description="Include archived groups. Default false.", + * required=false, + * in="query", + * @OA\Schema( + * type="boolean" + * ) + * ), + * @OA\Parameter( + * name="includeNextEvent", + * description="Include the next event for the group. This makes the call slower. Default false.", + * required=false, + * in="query", + * @OA\Schema( + * type="boolean" + * ) + * ), + * @OA\Parameter( + * name="includeCounts", + * description="Include the counts of hosts and restarters. This makes the call slower. Default false.", + * required=false, + * in="query", + * @OA\Schema( + * type="boolean" + * ) + * ), + * @OA\Parameter( + * name="ids", + * description="Comma-separated group ids. When present, only these groups are returned (used by the groups list to hydrate the visible rows). Maximum 200 ids.", + * required=false, + * in="query", + * @OA\Schema( + * type="string" + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property( + * property="data", + * title="data", + * description="An array of events", + * type="array", + * @OA\Items( + * @OA\Schema( + * ref="#/components/schemas/GroupSummary" + * ), + * ) + * ) + * ) + * ), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * ) + */ + + public static function listSummaryv2(Request $request) { + $request->validate([ + 'archived' => ['string', 'in:true,false'], + 'ids' => ['string', 'regex:/^\d+(,\d+)*$/', function ($attribute, $value, $fail) { + if (count(explode(',', $value)) > 200) { + $fail('A maximum of 200 ids may be requested at once.'); + } + }], + ]); + + // Eager-load everything the GroupSummary resource touches, otherwise + // each group lazy-loads its relations and the call scales O(N). + $query = Group::with(['networks', 'groupImage.image', 'group_tags']); + + if ($request->get('archived', 'false') !== 'true') { + $query = $query->whereNull('archived_at'); + } + + // The groups list hydrates just its visible rows this way, instead of + // paying to serialise every group on page load. + if ($request->filled('ids')) { + $query = $query->whereIn('idgroups', explode(',', $request->get('ids'))); + } + + $groups = $query->get(); + + return [ + 'data' => \App\Http\Resources\GroupSummaryCollection::make($groups) + ]; + } + /** * @OA\Get( * path="/api/v2/groups/tags", @@ -318,6 +447,11 @@ public static function listNamesv2(Request $request) { public static function listTagsv2(Request $request) { // Try session auth first, then API token auth $user = Auth::user(); + if (!$user) { + // SPA bearer tokens authenticate via the sanctum guard. + $user = auth('sanctum')->user(); + } + if (!$user) { $user = auth('api')->user(); } @@ -354,7 +488,7 @@ public static function listTagsv2(Request $request) { * operationId="getGroup", * tags={"Groups"}, * summary="Get Group", - * description="Returns information about a group.", + * description="Returns information about a group. Not behind auth - if a user is authenticated (via session or bearer token) the response also includes a permissions block of UI flags for that user; anonymous requests get all-false flags.", * @OA\Parameter( * name="id", * description="Group id", @@ -371,19 +505,86 @@ public static function listTagsv2(Request $request) { * @OA\Property( * property="data", * title="data", - * ref="#/components/schemas/Group" + * allOf={ + * @OA\Schema(ref="#/components/schemas/Group"), + * @OA\Schema( + * @OA\Property( + * property="permissions", + * type="object", + * description="UI show/hide flags for the requesting user against this group. Advisory only - the mutating endpoints enforce their own authorization independently.", + * @OA\Property(property="can_edit", type="boolean"), + * @OA\Property(property="can_demote", type="boolean"), + * @OA\Property(property="can_see_delete", type="boolean"), + * @OA\Property(property="can_perform_delete", type="boolean"), + * @OA\Property(property="can_perform_archive", type="boolean") + * ) + * ) + * } * ) * ) * ), - * @OA\Response( - * response=404, - * description="Group not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ public static function getGroupv2(Request $request, $idgroups) { $group = Group::findOrFail($idgroups); - return \App\Http\Resources\Group::make($group); + + // This endpoint is not behind auth:api, so the request may be anonymous. Try session auth first (the + // group view page is loaded via a normal browser session), then fall back to API token auth. If there is + // no authenticated user at all, every permission flag is false. + $user = Auth::user(); + if (! $user) { + // SPA bearer tokens authenticate via the sanctum guard. + $user = auth('sanctum')->user(); + } + if (! $user) { + $user = auth('api')->user(); + } + + $permissions = self::groupPermissionsFor($user, $group); + + return \App\Http\Resources\Group::make($group)->additional([ + 'data' => [ + 'permissions' => $permissions, + ], + ]); + } + + /** + * Compute the UI show/hide permission flags for a given (possibly null) user against a group. + * + * These flags are for UI purposes only - the actual edit/delete/archive endpoints enforce their own + * authorization independently. Mirrors the logic previously computed server-side in group/view.blade.php. + */ + private static function groupPermissionsFor(?User $user, Group $group): array + { + if (! $user) { + return [ + 'can_edit' => false, + 'can_demote' => false, + 'can_see_delete' => false, + 'can_perform_delete' => false, + 'can_perform_archive' => false, + ]; + } + + $isAdministrator = Fixometer::hasRole($user, 'Administrator'); + $isCoordinatorForGroup = $user->isCoordinatorForGroup($group); + $isHostOfGroup = Fixometer::userHasEditGroupPermission($group->idgroups, $user->id); + + $canEdit = $isAdministrator || $isCoordinatorForGroup || $isHostOfGroup; + $canDemote = $isAdministrator || $isCoordinatorForGroup; + $canSeeDelete = $isAdministrator; + $canPerformDelete = $canSeeDelete && $group->canDelete(); + $canPerformArchive = $isAdministrator || $isCoordinatorForGroup; + + return [ + 'can_edit' => $canEdit, + 'can_demote' => $canDemote, + 'can_see_delete' => $canSeeDelete, + 'can_perform_delete' => $canPerformDelete, + 'can_perform_archive' => $canPerformArchive, + ]; } /** @@ -437,10 +638,7 @@ public static function getGroupv2(Request $request, $idgroups) { * ) * ) * ), - * @OA\Response( - * response=404, - * description="Group not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -456,7 +654,18 @@ public static function getEventsForGroupv2(Request $request, $idgroups) { $start = Carbon::parse($request->get('start', '1970-01-01'))->setTimezone('UTC')->toIso8601String(); $end = Carbon::parse($request->get('end', '3000-01-01'))->setTimezone('UTC')->toIso8601String(); + // Eager-load the relations PartySummary::getEventStats() needs (per-event stats now shown + // on the group events list), the same way Group::bulkGroupStats() does - otherwise each + // event would trigger its own device/invited queries as the resource is built. + // + // theGroup (via GroupSummary, rendered per event too) is the same group every time here, + // but Eloquent doesn't dedupe lazy loads across model instances - without eager-loading it + // (and groupImage.image/networks, which GroupSummary touches unconditionally, same set + // listSummaryv2 eager-loads) this still scales with event count, just less obviously than + // the stats N+1. $parties = Party::undeleted()->forGroup($idgroups) + ->with('allDevices', 'theGroup.networks', 'theGroup.groupImage.image') + ->withCount('allInvited') ->where('event_start_utc', '>=', $start) ->where('event_end_utc', '<=', $end) ->get(); @@ -481,6 +690,15 @@ public static function getEventsForGroupv2(Request $request, $idgroups) { * type="integer" * ) * ), + * @OA\Parameter( + * name="exclude_event", + * description="Event id. When present, excludes users already confirmed as a volunteer at that event.", + * required=false, + * in="query", + * @OA\Schema( + * type="integer" + * ) + * ), * @OA\Response( * response=200, * description="Successful operation", @@ -496,10 +714,7 @@ public static function getEventsForGroupv2(Request $request, $idgroups) { * ) * ) * ), - * @OA\Response( - * response=404, - * description="Group not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -529,7 +744,8 @@ public function getVolunteersForGroupv2(Request $request, $idgroups) { * operationId="deleteVolunteerForGroupv2", * tags={"Groups","Volunteers"}, * summary="Delete Group Volunteer", - * description="Removes a volunteer from a group", + * description="Removes a volunteer from a group. Requires administrator, network-coordinator-for-group, or host-of-group permission - an authenticated user lacking that permission still gets 401 (not 403), since this check is implemented via AuthenticationException.", + * security={{"apiToken":{}}}, * @OA\Parameter( * name="id", * description="Group id", @@ -552,10 +768,8 @@ public function getVolunteersForGroupv2(Request $request, $idgroups) { * response=200, * description="Successful operation", * ), - * @OA\Response( - * response=404, - * description="Group not found", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -584,7 +798,8 @@ public function deleteVolunteerForGroupv2(Request $request, $id, $iduser) * operationId="patchVolunteerForGroupv2", * tags={"Groups","Volunteers"}, * summary="Modify Group Volunteer", - * description="Modify a volunteer's status on a group", + * description="Modify a volunteer's host/restarter role on a group. Requires administrator, network-coordinator-for-group, or host-of-group permission - an authenticated user lacking that permission still gets 401 (not 403), since this check is implemented via AuthenticationException.", + * security={{"apiToken":{}}}, * @OA\Parameter( * name="id", * description="Group id", @@ -595,22 +810,30 @@ public function deleteVolunteerForGroupv2(Request $request, $id, $iduser) * ) * ), * @OA\Parameter( - * name="host", - * description="Host", + * name="iduser", + * description="User id", * required=true, * in="path", * @OA\Schema( - * type="boolean" + * type="integer" + * ) + * ), + * @OA\RequestBody( + * @OA\JsonContent( + * @OA\Property( + * property="host", + * description="Promote the volunteer to host (true) or demote to restarter (false). Defaults to false when omitted.", + * type="boolean", + * default=false + * ) * ) * ), * @OA\Response( * response=200, * description="Successful operation", * ), - * @OA\Response( - * response=404, - * description="Group not found", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -641,23 +864,6 @@ public function patchVolunteerForGroupv2(Request $request, $id, $iduser) } } - private function getUser() { - // We want to allow this call to work if a) we are logged in as a user, or b) we have a valid API token. - // - // This is a slightly odd thing to do, but it is necessary to get both the PHPUnit tests and the - // real client use of the API to work. - $user = Auth::user(); - - if (!$user) { - $user = auth('api')->user(); - } - - if (!$user) { - throw new AuthenticationException(); - } - - return $user; - } /** * @OA\Get( @@ -665,17 +871,8 @@ private function getUser() { * operationId="getGroupsModeratev2", * tags={"Groups"}, * summary="Get Groups for Moderation", - * description="Only available for Administrators and Network Coordinators. ", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Unapproved groups visible to the authenticated user: Administrators see all, Network Coordinators see groups in networks they coordinate. Other authenticated users get an empty array (no error).", + * security={{"apiToken":{}}}, * @OA\Response( * response=200, * description="Successful operation", @@ -687,8 +884,104 @@ private function getUser() { * ) * ) * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), * ) */ + /** + * @OA\Get( + * path="/api/v2/groups/{id}/audits", + * operationId="getGroupAuditsv2", + * tags={"Groups"}, + * summary="Audit trail for a group", + * description="Backs the edit page's Group log tab. Administrator only, matching the legacy edit view's gate. Strings are rendered server-side from the group-audits lang files, so the placeholder substitution stays in one place; heading and changes are HTML.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Audit entries, newest first", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="event", type="string", example="updated"), + * @OA\Property(property="heading", type="string"), + * @OA\Property(property="changes", type="array", @OA\Items(type="string")) + * ))) + * ), + * @OA\Response(response=403, description="Not an administrator"), + * @OA\Response(response=404, description="No such group") + * ) + */ + public function auditsv2($id): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $group = Group::find($id); + + if (! $group) { + return response()->json(['error' => 'No such group'], 404); + } + + // Same shape and rationale as EventController::auditsv2 - see there. + $audits = $group->audits()->with('user')->orderBy('created_at', 'desc')->get(); + + return response()->json([ + 'data' => $audits->map(function ($audit) { + $changes = []; + + foreach ($audit->getModified() as $attribute => $modified) { + $key = 'group-audits.'.$audit->event.'.modified.'.$attribute; + $line = __($key, $modified); + + if ($line !== $key) { + $changes[] = $line; + } + } + + // SECURITY: audit_url is the full request URL, so for any write + // authenticated with ?api_token= it contains a VALID API TOKEN. + // laravel-auditing stores that verbatim, and the legacy view + // renders it to any Administrator opening the log. Strip the + // query string before rendering. NB this only stops the + // display. New rows no longer carry one either + // (App\Auditing\SanitisedUrlResolver), and `php artisan + // audits:scrub-urls` clears any written before that landed - + // this stays as defence in depth for environments that have + // not run it. + $metadata = $audit->getMetadata(); + + if (isset($metadata['audit_url']) && is_string($metadata['audit_url'])) { + $metadata['audit_url'] = strtok($metadata['audit_url'], '?'); + } + + $headingKey = 'group-audits.'.$audit->event.'.metadata'; + $heading = __($headingKey, $metadata); + + return [ + 'id' => $audit->id, + 'event' => $audit->event, + 'heading' => $heading === $headingKey ? null : $heading, + 'changes' => $changes, + ]; + })->values()->all(), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/moderate/groups", + * operationId="moderateGroupsv2", + * tags={"Groups"}, + * summary="Groups awaiting moderation", + * description="Unapproved groups visible to the caller - every group for an Administrator, a NetworkCoordinator's own networks otherwise.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Groups awaiting moderation. A bare array, not a {data:...} envelope - response()->json() on a resource collection bypasses Laravel's Responsable wrapping.", + * @OA\JsonContent(type="array", @OA\Items(type="object")) + * ) + * ) + */ public function moderateGroupsv2(Request $request): JsonResponse { $user = $this->getUser(); $ret = \App\Http\Resources\GroupCollection::make(Group::unapprovedVisibleTo($user->id)); @@ -701,17 +994,8 @@ public function moderateGroupsv2(Request $request): JsonResponse { * operationId="createGroup", * tags={"Groups"}, * summary="Create Group", - * description="Creates a group.", - * @OA\Parameter( - * name="api_token", - * description="A valid user API token", - * required=true, - * in="query", - * @OA\Schema( - * type="string", - * example="1234" - * ) - * ), + * description="Creates a group and adds the authenticated user as its host (converting them to a host if not already one). Notifies admins with the admin-moderate-group preference for approval.", + * security={{"apiToken":{}}}, * @OA\RequestBody( * @OA\MediaType( * mediaType="multipart/form-data", @@ -762,13 +1046,11 @@ public function moderateGroupsv2(Request $request): JsonResponse { * response=200, * description="Successful operation", * @OA\JsonContent( - * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Group" - * ) - * ), - * ) + * @OA\Property(property="id", type="integer", description="Id of the newly-created group", example=1) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), * ) */ public function createGroupv2(Request $request): JsonResponse { @@ -843,22 +1125,21 @@ public function createGroupv2(Request $request): JsonResponse { * operationId="editGroup", * tags={"Groups"}, * summary="Edit Group", - * description="Edit a group.", + * description="Edit a group. Requires administrator, network-coordinator-for-group, or host-of-group permission. `area`, `postcode` and `archived_at` are only persisted for an administrator or a network coordinator of the group's network(s); a host's submitted values for those fields are silently ignored.", + * security={{"apiToken":{}}}, * @OA\Parameter( - * name="api_token", - * description="A valid user API token", + * name="id", + * description="Group id", * required=true, - * in="query", + * in="path", * @OA\Schema( - * type="string", - * example="1234" + * type="integer" * ) * ), * @OA\RequestBody( * @OA\MediaType( * mediaType="multipart/form-data", * @OA\Schema( - * required={"name","location","description"}, * @OA\Property( * property="name", * ref="#/components/schemas/Group/properties/name", @@ -900,8 +1181,39 @@ public function createGroupv2(Request $request): JsonResponse { * @OA\Property( * property="archived_at", * title="archived_at", - * description="If present, this group has been archived and is no longer active.", + * description="If present, this group has been archived and is no longer active. Administrator/network-coordinator only.", * format="date-time", + * ), + * @OA\Property( + * property="area", + * description="Administrator/network-coordinator only.", + * type="string", + * nullable=true + * ), + * @OA\Property( + * property="postcode", + * description="Administrator/network-coordinator only.", + * type="string", + * nullable=true + * ), + * @OA\Property( + * property="networks", + * description="JSON-encoded array of network ids to associate with the group (replaces the existing set). Administrator only.", + * type="string", + * example="[1,2]" + * ), + * @OA\Property( + * property="tags", + * description="JSON-encoded array of tag ids to associate with the group (replaces the existing set). Administrators may use any tag (global or any network's); network coordinators may only submit tags belonging to networks they coordinate that the group is also a member of - existing tags outside that scope are preserved automatically.", + * type="string", + * example="[3,4]" + * ), + * @OA\Property( + * property="moderate", + * description="Set to ""approve"" to approve a pending group. Administrator or network-coordinator-for-group only; ignored otherwise.", + * type="string", + * enum={"approve"}, + * nullable=true * ) * ) * ) @@ -910,13 +1222,13 @@ public function createGroupv2(Request $request): JsonResponse { * response=200, * description="Successful operation", * @OA\JsonContent( - * @OA\Property( - * property="data", - * title="data", - * ref="#/components/schemas/Group" - * ) - * ), - * ) + * @OA\Property(property="id", type="string", description="Id of the edited group", example=1) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), * ) */ public function updateGroupv2(Request $request, $idGroup): JsonResponse { @@ -962,6 +1274,25 @@ public function updateGroupv2(Request $request, $idGroup): JsonResponse { $data['archived_at'] = $archived_at; } + // This is a PATCH, so a field the caller did not send must be left + // ALONE. $request->input() returns null for an absent key, and writing + // that null wiped real data: a PATCH sending only {name, phone} + // cleared the group's location, latitude and longitude outright. + // Observed against the parity fixtures. + // + // latitude/longitude/country_code are derived from `location` by the + // geocoder above rather than sent by the caller, so they follow + // whether `location` was sent. + $derivedFromLocation = ['latitude', 'longitude', 'country_code']; + + $data = array_filter($data, function ($value, $field) use ($request, $derivedFromLocation) { + $sentAs = in_array($field, $derivedFromLocation, true) + ? 'location' + : ($field === 'free_text' ? 'description' : $field); + + return $request->has($sentAs); + }, ARRAY_FILTER_USE_BOTH); + if (isset($_FILES) && !empty($_FILES)) { // Update the group image. $file = new \FixometerFile(); @@ -1115,7 +1446,10 @@ private function validateGroupParams(Request $request, $create, ?Group $existing $name = $request->input('name'); $area = $request->input('area'); - $postcode = $request->input('postcode', ''); + // NOT NULL in the schema, and ConvertEmptyStringsToNull rewrites a + // submitted '' to null before this runs - so an explicitly-sent null + // means "clear it", which for this column is '' rather than null. + $postcode = $request->input('postcode') ?? ''; $location = $request->input('location'); $phone = $request->input('phone'); $website = $request->input('website'); diff --git a/app/Http/Controllers/API/GroupMembershipController.php b/app/Http/Controllers/API/GroupMembershipController.php new file mode 100644 index 0000000000..7580b3554c --- /dev/null +++ b/app/Http/Controllers/API/GroupMembershipController.php @@ -0,0 +1,669 @@ +user(); + $group = Group::findOrFail($id); + + $alreadyMember = UserGroups::where('group', $group->idgroups) + ->where('user', $user->id) + ->where('status', 1) + ->exists(); + + if (! $alreadyMember) { + UserGroups::updateOrCreate([ + 'user' => $user->id, + 'group' => $group->idgroups, + ], [ + 'status' => 1, + 'role' => Role::RESTARTER, + ]); + + event(new UserFollowedGroup($user, $group)); + + $groupHosts = UserGroups::where('group', $group->idgroups)->where('role', Role::HOST)->get(); + + foreach ($groupHosts as $groupHostLink) { + $host = User::find($groupHostLink->user); + + if ($host) { + Notification::send($host, new NewGroupMember([ + 'user_name' => $user->name, + 'group_name' => $group->name, + 'group_url' => url('/group/view/'.$group->idgroups), + ], $host)); + } + } + } + + return response()->json([ + 'data' => [ + 'joined' => true, + 'already_member' => $alreadyMember, + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/groups/{id}/members/me", + * operationId="leaveGroupv2", + * tags={"Groups"}, + * summary="Leave a group as the current user", + * description="Idempotent: leaving a group you are not a member of still returns success. 403 is also returned when the authenticated user has not yet given the required data consents (see GET /api/v2/session).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Left", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="left", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) + */ + public function leavev2(Request $request, $id): JsonResponse + { + $user = $request->user(); + + $member = UserGroups::where('group', $id) + ->where('user', $user->id) + ->where('status', 1) + ->first(); + + // Mirrors UserGroupsController::leave: a missing membership could just mean the user + // already left (double-click, etc) - that's still a successful outcome. + if ($member) { + $member->delete(); + } + + return response()->json(['data' => ['left' => true]]); + } + + /** + * @OA\Get( + * path="/api/v2/groups/nearby", + * operationId="getNearbyGroupsv2", + * tags={"Groups"}, + * summary="Groups near the current user's location", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Nearby groups (empty if the user has no location set)", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="distance", type="number"), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="image_url", type="string", nullable=true) + * ))) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function nearbyv2(Request $request): JsonResponse + { + $user = $request->user(); + + // User::groupsNearby() already returns [] when the user has no lat/lng - no need to + // special-case it here. + $groups = $user->groupsNearby(); + + return response()->json([ + 'data' => array_map(fn (Group $group) => $group->toNearbySummary(), $groups), + ]); + } + + /** + * @OA\Post( + * path="/api/v2/groups/{id}/invites", + * operationId="inviteToGroupv2", + * tags={"Groups"}, + * summary="Invite people to a group by email", + * description="Requires administrator, network-coordinator-for-group, or host-of-group permission. Mirrors GroupController@postSendInvite. 403 is also returned when the authenticated user has not yet given the required data consents (see GET /api/v2/session).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"emails"}, + * @OA\Property(property="emails", type="array", @OA\Items(type="string")), + * @OA\Property(property="message", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Invites processed", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="invites_sent", type="integer"), + * @OA\Property(property="invalid", type="array", @OA\Items(type="string")) + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function invitesv2(Request $request, $id): JsonResponse + { + $user = $request->user(); + $group = Group::findOrFail($id); + + $this->authorizeGroupEdit($user, $group); + + $request->validate([ + 'emails' => 'required|array|min:1', + 'emails.*' => 'string', + 'message' => 'nullable|string', + ]); + + $message = $request->input('message'); + + $valid = []; + $invalid = []; + + foreach ($request->input('emails') as $email) { + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $valid[] = $email; + } else { + $invalid[] = $email; + } + } + + $existingUsers = User::whereIn('email', $valid)->get(); + $nonUserEmails = array_values(array_diff($valid, $existingUsers->pluck('email')->all())); + + foreach ($existingUsers as $existingUser) { + $userGroup = UserGroups::where('user', $existingUser->id)->where('group', $group->idgroups)->first(); + + // Already a confirmed member, or already has an outstanding invite - nothing to do. + if ($userGroup && $userGroup->status == '1') { + continue; + } + + $hash = Fixometer::generateHash(); + $url = url('/group/accept-invite/'.$group->idgroups.'/'.$hash); + + if ($userGroup) { + $userGroup->update(['status' => $hash]); + } else { + UserGroups::create([ + 'user' => $existingUser->id, + 'group' => $group->idgroups, + 'status' => $hash, + 'role' => Role::RESTARTER, + ]); + } + + if ($existingUser->invites == 1) { + Notification::send($existingUser, new JoinGroup([ + 'name' => $user->name, + 'group' => $group->name, + 'url' => $url, + 'message' => $message, + ], $existingUser)); + } + } + + foreach ($nonUserEmails as $nonUserEmail) { + $hash = Fixometer::generateHash(); + + $invite = Invite::create([ + 'record_id' => $group->idgroups, + 'email' => $nonUserEmail, + 'hash' => $hash, + 'type' => 'group', + ]); + + Notification::send($invite, new JoinGroup([ + 'name' => $user->name, + 'group' => $group->name, + 'url' => url('/user/register/'.$hash), + 'message' => $message, + ])); + } + + return response()->json([ + 'data' => [ + 'invites_sent' => count($valid), + 'invalid' => $invalid, + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/groups/{id}", + * operationId="archiveGroupv2", + * tags={"Groups"}, + * summary="Archive a group", + * description="Sets archived_at; does not delete the group or its history. Requires administrator or network-coordinator-for-group permission (mirrors the Group resource's can_perform_archive flag). Also requires the group to have no event with a device (Group::canDelete()), and returns 403 when the authenticated user has not yet given the required data consents (see GET /api/v2/session).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Archived", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="archived", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function archivev2(Request $request, $id): JsonResponse + { + $user = $request->user(); + $group = Group::findOrFail($id); + + $isAdministrator = Fixometer::hasRole($user, 'Administrator'); + $isCoordinatorForGroup = $user->isCoordinatorForGroup($group); + + if (! $isAdministrator && ! $isCoordinatorForGroup) { + abort(403); + } + + // Preserve the pre-cutover protection: a group with an event that has a + // device cannot be removed (Group::canDelete()). This mirrors the old + // GET /group/delete rule (which redirected to /user/forbidden) and the + // can_perform_delete flag the SPA gates its delete button on + // (GroupController::groupPermissionsFor). + if (! $group->canDelete()) { + abort(403); + } + + $group->update(['archived_at' => now()]); + + return response()->json(['data' => ['archived' => true]]); + } + + /** + * @OA\Delete( + * path="/api/v2/groups/{id}/permanent", + * operationId="deleteGroupPermanentlyv2", + * tags={"Groups"}, + * summary="Permanently delete a group and its events", + * description="Administrator only, and only for a group with no event that has a device (Group::canDelete). This is the hard delete - DELETE /api/v2/groups/{id} archives instead, which is reversible and available to coordinators too.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Group id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deletePermanentlyv2(Request $request, $id): JsonResponse + { + $user = $request->user(); + $group = Group::findOrFail($id); + + // Administrator only - stricter than archive, which coordinators may + // also do. Matches can_see_delete in GroupController::groupPermissionsFor + // and the Auth::user()->hasRole('Administrator') gate on the web + // GroupController::delete this replaces. + if (! Fixometer::hasRole($user, 'Administrator')) { + abort(403); + } + + if (! $group->canDelete()) { + abort(403); + } + + // The group's events go too, including soft-deleted and future ones. + // canDelete() has already established none of them has a device, so + // nothing with recorded repair data is destroyed here. events_users + // rows are not cascaded in the DB, so they need removing first or the + // event delete hits a constraint violation - force, not soft, for the + // same reason. + $events = Party::withTrashed()->where('events.group', $group->idgroups)->get(); + + foreach ($events as $event) { + EventsUsers::where('event', $event->idevents)->get() + ->each(fn ($eventUser) => $eventUser->forceDelete()); + + $event->forceDelete(); + } + + $group->delete(); + + return response()->json(['data' => ['deleted' => true]]); + } + + /** + * @OA\Get( + * path="/api/v2/groups/{id}/stats", + * operationId="getGroupStatsv2", + * tags={"Groups"}, + * summary="Statistics block for the group view page", + * description="Mirrors the group_stats/device_stats/cluster_stats/top_devices props GroupController@view (Blade) passes to group/view.blade.php.", + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Group statistics", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="group_stats", type="object"), + * @OA\Property(property="device_stats", type="object", + * @OA\Property(property="fixed", type="integer"), + * @OA\Property(property="repairable", type="integer"), + * @OA\Property(property="dead", type="integer") + * ), + * @OA\Property(property="cluster_stats", type="object", description="Keyed by cluster id (1-4)"), + * @OA\Property(property="top_devices", type="array", @OA\Items( + * @OA\Property(property="name", type="string"), + * @OA\Property(property="counter", type="integer") + * )) + * )) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function statsv2(Request $request, $id): JsonResponse + { + $group = Group::findOrFail($id); + $device = new Device(); + + return response()->json([ + 'data' => [ + 'group_stats' => $group->getGroupStats(), + 'device_stats' => self::deviceStats($device, $group->idgroups), + 'cluster_stats' => self::clusterStats($device, $group->idgroups), + 'top_devices' => self::topDevices($device, $group->idgroups), + ], + ]); + } + + private static function deviceStats(Device $device, int $idgroups): array + { + $stats = ['fixed' => 0, 'repairable' => 0, 'dead' => 0]; + + foreach ($device->statusCount($idgroups) as $count) { + if ($count->status == Device::REPAIR_STATUS_FIXED) { + $stats['fixed'] = (int) $count->counter; + } elseif ($count->status == Device::REPAIR_STATUS_REPAIRABLE) { + $stats['repairable'] = (int) $count->counter; + } elseif ($count->status == Device::REPAIR_STATUS_ENDOFLIFE) { + $stats['dead'] = (int) $count->counter; + } + } + + return $stats; + } + + private static function clusterStats(Device $device, int $idgroups): array + { + // Same template/accumulation as GroupController@view (Blade): repair_status 1/2/3 land + // in array positions 0/1/2 respectively. + $template = [ + 0 => ['counter' => 0], + 1 => ['counter' => 0], + 2 => ['counter' => 0], + 'total' => 0, + ]; + $clusters = [1 => $template, 2 => $template, 3 => $template, 4 => $template]; + + foreach ($device->countByClustersYearStatus($idgroups) as $count) { + $cluster = $count->cluster; + $repair_status = $count->repair_status; + + if ($repair_status && $cluster && array_key_exists($cluster, $clusters)) { + $clusters[$cluster][$repair_status - 1]['counter'] += $count->counter; + $clusters[$cluster]['total'] += $count->counter; + } + } + + $ret = []; + + foreach ([1, 2, 3, 4] as $cluster) { + $mostSeen = $device->findMostSeen(null, $cluster, $idgroups); + $mostRepaired = $device->findMostSeen(Device::REPAIR_STATUS_FIXED, $cluster, $idgroups); + $leastRepaired = $device->findMostSeen(Device::REPAIR_STATUS_ENDOFLIFE, $cluster, $idgroups); + + $ret[$cluster] = [ + 'fixed' => (int) $clusters[$cluster][0]['counter'], + 'repairable' => (int) $clusters[$cluster][1]['counter'], + 'dead' => (int) $clusters[$cluster][2]['counter'], + 'total' => (int) $clusters[$cluster]['total'], + 'most_seen' => self::namedCount($mostSeen), + 'most_repaired' => self::namedCount($mostRepaired), + 'least_repaired' => self::namedCount($leastRepaired), + ]; + } + + return $ret; + } + + private static function namedCount(array $rows): array + { + return [ + 'name' => $rows[0]->name ?? null, + 'count' => isset($rows[0]->counter) ? (int) $rows[0]->counter : 0, + ]; + } + + private static function topDevices(Device $device, int $idgroups): array + { + $rows = $device->findMostSeen(Device::REPAIR_STATUS_FIXED, null, $idgroups); + + return array_map(fn ($row) => ['name' => $row->name, 'counter' => (int) $row->counter], $rows); + } + + /** + * @OA\Post( + * path="/api/v2/groups/{id}/images", + * operationId="uploadGroupImagev2", + * tags={"Groups"}, + * summary="Attach a completed tus upload as the group's image", + * description="Mirrors UserController::updateMyPhotov2 - upload the file to /api/tus first, then attach it here by upload_key. Requires administrator, network-coordinator-for-group, or host-of-group permission; 403 is also returned when the authenticated user has not yet given the required data consents (see GET /api/v2/session). 422 covers a missing/expired/oversized (>2MB) upload_key or a non jpeg/png/gif upload.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(required={"upload_key"}, @OA\Property(property="upload_key", type="string")) + * ), + * @OA\Response( + * response=200, + * description="Image attached", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="image_url", type="string") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function uploadImagev2(Request $request, $id): JsonResponse + { + $user = $request->user(); + $group = Group::findOrFail($id); + + $this->authorizeGroupEdit($user, $group); + + $validated = $request->validate([ + 'upload_key' => 'required|string', + ]); + + $cache = Tus::buildCache(); + $meta = $cache->get($validated['upload_key']); + $filePath = $meta['file_path'] ?? null; + + if (! $meta || ! $filePath || ! is_file($filePath)) { + throw ValidationException::withMessages([ + 'upload_key' => [__('groups.image_upload_error')], + ]); + } + + // Confirm the tus upload actually finished (offset === size), not just started. + if (($meta['offset'] ?? null) !== ($meta['size'] ?? null)) { + throw ValidationException::withMessages([ + 'upload_key' => [__('groups.image_upload_error')], + ]); + } + + // Max 2MB, matching the profile-photo contract (updateMyPhotov2). + if (filesize($filePath) > 2 * 1024 * 1024) { + $cache->delete($validated['upload_key']); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__('groups.image_upload_error')], + ]); + } + + $mime = @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $filePath); + + if (! in_array($mime, ['image/jpeg', 'image/png', 'image/gif'], true)) { + $cache->delete($validated['upload_key']); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__('groups.image_upload_error')], + ]); + } + + $file = new \FixometerFile(); + // $clear is hardcoded true inside uploadLocalFile(), so any existing group image xref + // is removed automatically before this one is attached. + $filename = $file->uploadLocalFile($filePath, 'image', $group->idgroups, env('TBL_GROUPS'), false, true); + + $cache->delete($validated['upload_key']); + @unlink($filePath); + + if (! $filename) { + throw ValidationException::withMessages([ + 'upload_key' => [__('groups.image_upload_error')], + ]); + } + + return response()->json([ + 'data' => [ + 'image_url' => url('/uploads/mid_'.$filename), + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/groups/{id}/images/{idimages}", + * operationId="deleteGroupImagev2", + * tags={"Groups"}, + * summary="Detach an image from a group", + * description="Requires administrator, network-coordinator-for-group, or host-of-group permission; 403 is also returned when the authenticated user has not yet given the required data consents (see GET /api/v2/session). 404 covers both an unknown group id and an idimages that doesn't reference an image on this group.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Parameter(name="idimages", description="The xref id linking the image to the group", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Deleted", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="deleted", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteImagev2(Request $request, $id, $idimages): JsonResponse + { + $user = $request->user(); + $group = Group::findOrFail($id); + + $this->authorizeGroupEdit($user, $group); + + // Scope the xref lookup to this group - unlike the legacy Blade ajaxDeleteImage(), which + // deletes by xref id alone with no ownership check. + $xref = Xref::where('idxref', $idimages) + ->where('reference', $group->idgroups) + ->where('reference_type', env('TBL_GROUPS')) + ->first(); + + if (! $xref) { + abort(404, 'Image not found for this group.'); + } + + // Matches FixometerFile::deleteImage(): remove the xref only, leaving the underlying + // file/images row in place. + $xref->delete(); + + return response()->json(['data' => ['deleted' => true]]); + } + + /** + * The "can edit this group" permission check shared by invites/image endpoints: mirrors + * updateGroupv2's gate (administrator, network coordinator for the group, or host of the + * group). + */ + private function authorizeGroupEdit(User $user, Group $group): void + { + $isAdministrator = Fixometer::hasRole($user, 'Administrator'); + $isHostOfGroup = Fixometer::userHasEditGroupPermission($group->idgroups, $user->id); + $isCoordinatorForGroup = $user->isCoordinatorForGroup($group); + + if (! $isAdministrator && ! $isHostOfGroup && ! $isCoordinatorForGroup) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/API/GroupTagController.php b/app/Http/Controllers/API/GroupTagController.php new file mode 100644 index 0000000000..13a3e5c125 --- /dev/null +++ b/app/Http/Controllers/API/GroupTagController.php @@ -0,0 +1,202 @@ +orderBy('tag_name', 'asc')->get(); + + return TagCollection::make($tags); + } + + /** + * @OA\Get( + * path="/api/v2/group-tags/{id}", + * operationId="getGroupTagv2", + * tags={"GroupTags"}, + * summary="Get a global group tag", + * description="Network-scoped tags are not visible here (404); fetch them via /api/v2/networks/{id}/tags.", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getGroupTagv2($id) + { + $tag = $this->findGlobalOrFail($id); + + return Tag::make($tag); + } + + /** + * @OA\Post( + * path="/api/v2/group-tags", + * operationId="createGroupTagv2", + * tags={"GroupTags"}, + * summary="Create a global group tag", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255, example="Scotland"), + * @OA\Property(property="description", type="string", maxLength=1000, nullable=true) + * ) + * ), + * @OA\Response( + * response=201, + * description="Group tag created", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function createGroupTagv2(Request $request): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $validated = $request->validate($this->validationRules()); + + $tag = GroupTags::create([ + 'tag_name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'network_id' => null, + ]); + + return response()->json(['data' => (new Tag($tag))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/group-tags/{id}", + * operationId="updateGroupTagv2", + * tags={"GroupTags"}, + * summary="Update a global group tag", + * description="Administrator only. Network-scoped tags must be updated via /api/v2/networks/{id}/tags.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255), + * @OA\Property(property="description", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Group tag updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateGroupTagv2(Request $request, $id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $tag = $this->findGlobalOrFail($id); + + $validated = $request->validate($this->validationRules($tag->id)); + + $tag->update([ + 'tag_name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + return Tag::make($tag->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/group-tags/{id}", + * operationId="deleteGroupTagv2", + * tags={"GroupTags"}, + * summary="Delete a global group tag", + * description="Administrator only. Network-scoped tags must be deleted via /api/v2/networks/{id}/tags.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Group tag deleted"), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteGroupTagv2($id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $tag = $this->findGlobalOrFail($id); + $tag->delete(); + + return response()->noContent(); + } + + /** + * Look up a global group tag, or throw a ModelNotFoundException so the + * caller cannot use this endpoint to reach into a network's tags. + */ + private function findGlobalOrFail(int $id): GroupTags + { + return GroupTags::global()->findOrFail($id); + } + + private function validationRules($ignoreId = null): array + { + // Uniqueness only within the global scope: a global tag and a + // network-scoped tag can share a name (they live in different scopes). + $uniqueRule = Rule::unique('group_tags', 'tag_name')->whereNull('network_id'); + if ($ignoreId !== null) { + $uniqueRule = $uniqueRule->ignore($ignoreId); + } + + return [ + 'name' => ['required', 'string', 'max:255', $uniqueRule], + 'description' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Controllers/API/NetworkController.php b/app/Http/Controllers/API/NetworkController.php index 00d7a6076a..66b2e2d4ac 100644 --- a/app/Http/Controllers/API/NetworkController.php +++ b/app/Http/Controllers/API/NetworkController.php @@ -82,10 +82,6 @@ private function statsForTag(Network $network, int $tagId): array * ) * ) * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), * ) */ @@ -122,10 +118,7 @@ public function getNetworksv2() * ) * ) * ), - * @OA\Response( - * response=404, - * description="Event not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -238,10 +231,7 @@ public function getNetworkv2($id) * ) * ) * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -377,10 +367,7 @@ public function getNetworkGroupsv2(Request $request, $id) * ) * ) * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ @@ -397,8 +384,21 @@ public function getNetworkEventsv2(Request $request, $id) // We need to explicity select events.*, otherwise the updated_at values we get back are from the group_network // table, which is mightily confusing. We only want to return approved events on approved groups. + // + // Both PartySummary and Party (full) call getEventStats() per event, which needs allDevices + // and allInvited loaded or it's an N+1 - same eager-load as GroupController::getEventsForGroupv2 + // and Group::bulkGroupStats(). This endpoint has no upper bound on the date range, so an + // unfiltered network can return a lot of events. + // + // theGroup is also rendered per event (via GroupSummary), and unlike + // getEventsForGroupv2 every event here can belong to a DIFFERENT group - so without + // eager-loading it (and the relations GroupSummary itself touches unconditionally: + // groupImage.image, networks - same set listSummaryv2 eager-loads) this scales with + // event count too, just less obviously than the stats N+1. $query = Party::join('groups', 'groups.idgroups', '=', 'events.group') ->join('group_network', 'group_network.group_id', '=', 'groups.idgroups') + ->with('allDevices', 'theGroup.networks', 'theGroup.groupImage.image') + ->withCount('allInvited') ->where('group_network.network_id', $id) ->where('event_start_utc', '>=', $start) ->where('event_end_utc', '<=', $end) @@ -414,7 +414,13 @@ public function getNetworkEventsv2(Request $request, $id) ->where('grouptags_groups.group_tag', $tagId); } - $events = $query->select('events.*')->get(); + // addSelect(), not select() - select() replaces the whole column list, which would + // silently discard the all_invited_count column withCount('allInvited') staged above via + // its own addSelect() (found by diffing the actual SQL between the 3-event and 6-event + // runs of testEventsQueryCountDoesNotScaleWithEventCount: it grew by exactly a + // lazy-loaded events_users query per new event - withCount() was running but its result + // was being thrown away before the query even executed). + $events = $query->addSelect('events.*')->get(); if ($request->get('includeDetails', false)) { return \App\Http\Resources\PartyCollection::make($events); @@ -463,10 +469,7 @@ public function getNetworkEventsv2(Request $request, $id) * ) * ) * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ public function getNetworkTagsv2(Request $request, $id) @@ -519,16 +522,19 @@ public function getNetworkTagsv2(Request $request, $id) * @OA\Property(property="fixed_unpowered", type="integer", example=13), * @OA\Property(property="repairable_devices", type="integer", example=520), * @OA\Property(property="dead_devices", type="integer", example=178), + * @OA\Property(property="unknown_repair_status", type="integer", example=22), + * @OA\Property(property="devices_powered", type="integer", example=610), + * @OA\Property(property="devices_unpowered", type="integer", example=90), + * @OA\Property(property="no_weight_powered", type="integer", example=4), + * @OA\Property(property="no_weight_unpowered", type="integer", example=1), * @OA\Property(property="participants", type="integer", example=880), * @OA\Property(property="volunteers", type="integer", example=556), * @OA\Property(property="hours_volunteered", type="integer", example=3152), + * @OA\Property(property="invited", type="integer", example=940), * @OA\Property(property="parties", type="integer", example=161) * ) * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ public function getNetworkStatsv2(Request $request, $id): JsonResponse @@ -588,22 +594,10 @@ public function getNetworkStatsv2(Request $request, $id): JsonResponse * ) * ) * ), - * @OA\Response( - * response=401, - * description="Unauthenticated", - * ), - * @OA\Response( - * response=403, - * description="Forbidden - User is not a coordinator for this network", - * ), - * @OA\Response( - * response=404, - * description="Network not found", - * ), - * @OA\Response( - * response=422, - * description="Validation error - tag name already exists in this network", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), * ) */ public function createNetworkTagv2(Request $request, $id) @@ -683,23 +677,17 @@ public function createNetworkTagv2(Request $request, $id) * @OA\Response( * response=200, * description="Tag updated successfully", + * @OA\JsonContent( + * @OA\Property( + * property="data", + * ref="#/components/schemas/Tag" + * ) + * ) * ), - * @OA\Response( - * response=401, - * description="Unauthenticated", - * ), - * @OA\Response( - * response=403, - * description="Forbidden - User is not a coordinator for this network", - * ), - * @OA\Response( - * response=404, - * description="Network or tag not found", - * ), - * @OA\Response( - * response=422, - * description="Validation error or duplicate tag name", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), * ) */ public function updateNetworkTagv2(Request $request, $id, $tagId) @@ -777,19 +765,13 @@ public function updateNetworkTagv2(Request $request, $id, $tagId) * @OA\Response( * response=200, * description="Tag deleted successfully", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="Tag deleted successfully") + * ) * ), - * @OA\Response( - * response=401, - * description="Unauthenticated", - * ), - * @OA\Response( - * response=403, - * description="Forbidden - User is not a coordinator for this network or tag is global", - * ), - * @OA\Response( - * response=404, - * description="Network or tag not found", - * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), * ) */ public function deleteNetworkTagv2(Request $request, $id, $tagId) @@ -820,4 +802,153 @@ public function deleteNetworkTagv2(Request $request, $id, $tagId) return response()->json(['message' => 'Tag deleted successfully']); } + + /** + * @OA\Post( + * path="/api/v2/networks/{id}/groups", + * operationId="associateNetworkGroups", + * tags={"Networks"}, + * summary="Associate groups with a network", + * description="Add one or more groups to a network. Requires authentication as a Network Coordinator for this network or an Administrator.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Network id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"groups"}, + * @OA\Property(property="groups", type="array", minItems=1, @OA\Items(type="integer"), description="Group ids to add to the network. Unknown ids are silently skipped."), + * ) + * ), + * @OA\Response(response=200, description="Groups associated", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="associated", type="integer", description="Number of groups actually found and associated")))), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * ) + * + * Port of NetworkController::associateGroup (the old session+CSRF web form). + */ + public function associateGroupsv2(Request $request, $id): JsonResponse + { + $network = Network::findOrFail($id); + $user = Auth::user(); + + if (! $user) { + return response()->json(['message' => 'Unauthenticated'], 401); + } + + if (! $user->hasRole('Administrator') && ! $user->isCoordinatorOf($network)) { + return response()->json(['message' => 'You do not have permission to add groups to this network'], 403); + } + + $validated = $request->validate([ + 'groups' => 'required|array|min:1', + 'groups.*' => 'integer', + ]); + + $associated = 0; + foreach ($validated['groups'] as $groupId) { + $group = Group::find($groupId); + if ($group) { + $network->addGroup($group); + $associated++; + } + } + + return response()->json(['data' => ['associated' => $associated]]); + } + + /** + * @OA\Post( + * path="/api/v2/networks/{id}/logo", + * operationId="uploadNetworkLogo", + * tags={"Networks"}, + * summary="Upload a network logo", + * description="Set the network's logo from a completed tus upload. Requires authentication as a Network Coordinator for this network or an Administrator.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Network id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"upload_key"}, + * @OA\Property(property="upload_key", type="string", description="Key of the completed tus upload"), + * ) + * ), + * @OA\Response( + * response=200, + * description="Logo stored", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="logo", type="string"))) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError"), + * ) + * + * Port of NetworkController::update's network_logo handling: stores the + * image under network_logos/ (with a -_x100 sized copy) and sets + * network->logo. The SPA uploads the file via tus first, then calls this + * with the resulting upload_key. + */ + public function uploadLogov2(Request $request, $id): JsonResponse + { + $network = Network::findOrFail($id); + $user = Auth::user(); + + if (! $user) { + return response()->json(['message' => 'Unauthenticated'], 401); + } + + if (! $user->hasRole('Administrator') && ! $user->isCoordinatorOf($network)) { + return response()->json(['message' => 'You do not have permission to edit this network'], 403); + } + + $validated = $request->validate([ + 'upload_key' => 'required|string', + ]); + + if (! config('restarters.features.image_upload')) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'upload_key' => [__('events.image_upload_error')], + ]); + } + + // Resolve (and validate: complete, <=2MB, image mime) the tus upload. + $tusPath = EventAttendanceController::validatedTusFilePath($validated['upload_key'], 'events'); + + $extByMime = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + ]; + $mime = @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tusPath); + $ext = $extByMime[$mime] ?? 'jpg'; + + // Same disk selection as the old web controller (s3 on Fly, else public). + $disk = config('filesystems.default') === 's3' ? 's3' : 'public_uploads'; + $storage = \Illuminate\Support\Facades\Storage::disk($disk); + + $path = 'network_logos/'.\Illuminate\Support\Str::random(40).'.'.$ext; + if (! $storage->put($path, file_get_contents($tusPath))) { + abort(500, 'Failed to save logo'); + } + + // Generate the _x100 sized version by copying the file (matches the + // old controller; the sized image is served at that derived path). + $sizedPath = preg_replace('/\.([^.\s]{3,4})$/', '-_x100.$1', $path); + $storage->copy($path, $sizedPath); + + $network->logo = $path; + $network->save(); + + // Clean up the consumed tus upload. + $cache = \App\Helpers\Tus::buildCache(); + $cache->delete($validated['upload_key']); + @unlink($tusPath); + + return response()->json(['data' => ['logo' => $network->logo]]); + } } diff --git a/app/Http/Controllers/API/RoleController.php b/app/Http/Controllers/API/RoleController.php new file mode 100644 index 0000000000..7e16abd174 --- /dev/null +++ b/app/Http/Controllers/API/RoleController.php @@ -0,0 +1,220 @@ +requireAdministrator()) { + return $resp; + } + + $rows = (new Role)->findAll(); + $permsByRole = $this->permissionsByRole(); + + $data = array_map(function ($row) use ($permsByRole) { + return (new RoleAdmin([ + 'id' => $row->id, + 'name' => $row->role, + 'permissions' => $permsByRole[$row->id] ?? [], + 'permissions_list' => $row->permissions_list ?? '', + ]))->toArray(request()); + }, $rows); + + return response()->json(['data' => $data]); + } + + /** + * @OA\Get( + * path="/api/v2/roles/{id}", + * operationId="getRolev2", + * tags={"Roles"}, + * summary="Get a single role", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/RoleAdmin")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getRolev2($id): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $role = $this->findRoleOr404($id); + $permissions = array_map( + fn ($p) => (int) $p->idpermissions, + (new Role)->rolePermissions($role->idroles) + ); + + $names = DB::select( + 'SELECT GROUP_CONCAT(permission ORDER BY permission SEPARATOR ", ") AS lst + FROM permissions + WHERE idpermissions IN (' . (count($permissions) ? implode(',', array_fill(0, count($permissions), '?')) : 'NULL') . ')', + $permissions + ); + $list = $names && isset($names[0]->lst) ? (string) $names[0]->lst : ''; + + return response()->json([ + 'data' => (new RoleAdmin([ + 'id' => $role->idroles, + 'name' => $role->role, + 'permissions' => $permissions, + 'permissions_list' => $list, + ]))->toArray(request()), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/permissions", + * operationId="listPermissionsv2", + * tags={"Roles"}, + * summary="List all permissions", + * description="Administrator only. Used to populate the role permission matrix.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Permission")) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) + */ + public function listPermissionsv2(): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $rows = DB::select('SELECT idpermissions AS id, permission AS name FROM permissions ORDER BY idpermissions ASC'); + $data = array_map( + fn ($r) => (new Permission(['id' => $r->id, 'name' => $r->name]))->toArray(request()), + $rows + ); + + return response()->json(['data' => $data]); + } + + /** + * @OA\Put( + * path="/api/v2/roles/{id}/permissions", + * operationId="updateRolePermissionsv2", + * tags={"Roles"}, + * summary="Replace the permissions granted to a role", + * description="Administrator only. `permissions` is the FULL desired set of permission IDs for this role, not a delta: the server deletes all existing grants for the role and re-inserts exactly the given IDs inside a DB transaction (Role::edit()), so any grant omitted from the array is revoked. `permissions` must be present but may be an empty array, which revokes every permission from the role. Every ID must reference an existing row in the permissions table (`exists:permissions,idpermissions`) or the whole request is rejected with 422 - no partial updates. `id` is the role's idroles primary key. Returns the same shape as GET /api/v2/roles/{id} on success.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), description="Role primary key (idroles)"), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"permissions"}, + * @OA\Property( + * property="permissions", + * type="array", + * description="Full replacement set of permission IDs to grant this role. Must reference existing permissions; an empty array revokes all permissions.", + * @OA\Items(type="integer"), + * example={4, 6} + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Permissions replaced", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/RoleAdmin")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateRolePermissionsv2(Request $request, $id): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $role = $this->findRoleOr404($id); + + $validated = $request->validate([ + 'permissions' => ['present', 'array'], + 'permissions.*' => ['integer', 'exists:permissions,idpermissions'], + ]); + + // Wrap the delete-then-reinsert in a transaction so a mid-update failure + // can't leave the role with a partial permission set. + $ok = DB::transaction(function () use ($role, $validated) { + return (new Role)->edit($role->idroles, array_map('intval', $validated['permissions'])); + }); + if (!$ok) { + return response()->json(['message' => 'Could not update permissions'], 500); + } + + return $this->getRolev2($role->idroles); + } + + private function findRoleOr404($id): Role + { + $role = Role::where('idroles', $id)->first(); + if (!$role) { + throw new NotFoundHttpException('Role not found.'); + } + return $role; + } + + /** + * One query → map of role id → list of permission ids granted to that role. + */ + private function permissionsByRole(): array + { + $rows = DB::select('SELECT role, permission FROM roles_permissions'); + $out = []; + foreach ($rows as $r) { + $out[(int) $r->role][] = (int) $r->permission; + } + return $out; + } +} diff --git a/app/Http/Controllers/API/SessionController.php b/app/Http/Controllers/API/SessionController.php new file mode 100644 index 0000000000..10ac932685 --- /dev/null +++ b/app/Http/Controllers/API/SessionController.php @@ -0,0 +1,189 @@ +json(['data' => self::sessionPayload(self::currentUser())]); + } + + /** + * @OA\Patch( + * path="/api/v2/session", + * operationId="patchSessionv2", + * tags={"Session"}, + * summary="Update session preferences (locale)", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="locale", type="string", example="fr-BE") + * ) + * ), + * @OA\Response( + * response=200, + * description="Updated session context", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="user", type="object", nullable=true), + * @OA\Property(property="config", type="object"), + * @OA\Property(property="flags", type="object") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function patchSessionv2(Request $request): JsonResponse + { + $request->validate([ + 'locale' => 'nullable|string|in:'.implode(',', self::availableLocales()), + ]); + + $user = $request->user(); + + if ($request->filled('locale')) { + $user->language = $request->input('locale'); + $user->save(); + } + + return response()->json(['data' => self::sessionPayload($user)]); + } + + public static function currentUser(): ?User + { + // Session (phpunit actingAs), then SPA bearer token, then legacy token. + return Auth::user() ?? auth('sanctum')->user() ?? auth('api')->user(); + } + + public static function sessionPayload(?User $user): array + { + return [ + 'user' => $user ? self::userPayload($user) : null, + 'config' => [ + 'discourse_url' => config('services.discourse.url'), + 'wiki_url' => config('restarters.wiki.base_url'), + 'gtm_id' => config('restarters.client.gtm_id'), + 'frontend_url' => config('restarters.frontend_url'), + 'community_test' => (bool) config('restarters.client.community_test'), + 'branch_banner' => config('restarters.client.show_branch_banner') ? [ + 'label' => config('restarters.client.branch_label') ?: config('app.env'), + 'mailpit_url' => config('restarters.client.mailpit_url'), + ] : null, + ], + 'flags' => [ + // Parity with develop: the legacy Blade layout gates the onboarding + // modal on a $onboarding view variable that is never assigned by any + // controller there, so the modal (and its /onboarding-complete route) + // is dead code on develop and no user ever sees it. We match that by + // always returning false here. The client component + // (DashboardOnboardingModal.vue) and the completion endpoint + // (POST /api/v2/users/me/onboarding-complete) are left in place but + // dormant; to deliberately revive the feature, replace this literal + // with real gating logic (e.g. $user->number_of_logins < 2). + 'onboarding' => false, + ], + ]; + } + + private static function userPayload(User $user): array + { + $profile = User::getProfile($user->id); + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'username' => $user->username, + 'avatar_url' => ($profile && $profile->path) ? url('/uploads/thumbnail_'.$profile->path) : null, + 'role' => (int) $user->role, + 'role_name' => optional($user->role()->first())->role, + 'language' => $user->language, + 'consent' => [ + 'gdpr' => ! is_null($user->consent_gdpr), + 'past_data' => ! is_null($user->consent_past_data), + 'future_data' => ! is_null($user->consent_future_data), + 'given' => (bool) $user->hasUserGivenConsent(), + ], + 'networks' => $user->networks->map(function ($network) { + return [ + 'id' => (int) $network->id, + 'name' => $network->name, + ]; + })->values()->all(), + ]; + } + + public static function availableLocales(): array + { + return collect(scandir(base_path('lang'))) + ->filter(fn ($entry) => is_dir(base_path('lang/'.$entry)) && ! str_starts_with($entry, '.')) + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/API/SkillController.php b/app/Http/Controllers/API/SkillController.php new file mode 100644 index 0000000000..b574af58f1 --- /dev/null +++ b/app/Http/Controllers/API/SkillController.php @@ -0,0 +1,191 @@ +get(); + + return SkillCollection::make($skills); + } + + /** + * @OA\Get( + * path="/api/v2/skills/{id}", + * operationId="getSkillv2", + * tags={"Skills"}, + * summary="Get a Skill", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getSkillv2($id) + { + $skill = Skills::findOrFail($id); + + return Skill::make($skill); + } + + /** + * @OA\Post( + * path="/api/v2/skills", + * operationId="createSkillv2", + * tags={"Skills"}, + * summary="Create a Skill", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"skill_name","category"}, + * @OA\Property(property="skill_name", type="string", maxLength=255, example="Soldering"), + * @OA\Property(property="category", type="integer", description="1 = Organising, 2 = Technical", example=2), + * @OA\Property(property="description", type="string", nullable=true, example="Surface-mount component rework") + * ) + * ), + * @OA\Response( + * response=201, + * description="Skill created", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function createSkillv2(Request $request): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $validated = $request->validate($this->validationRules()); + + $skill = Skills::create($validated); + + return response()->json(['data' => (new Skill($skill))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/skills/{id}", + * operationId="updateSkillv2", + * tags={"Skills"}, + * summary="Update a Skill", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"skill_name","category"}, + * @OA\Property(property="skill_name", type="string", maxLength=255, example="Soldering"), + * @OA\Property(property="category", type="integer", example=2), + * @OA\Property(property="description", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Skill updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateSkillv2(Request $request, $id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $skill = Skills::findOrFail($id); + + $validated = $request->validate($this->validationRules($skill->id)); + + $skill->update($validated); + + return Skill::make($skill->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/skills/{id}", + * operationId="deleteSkillv2", + * tags={"Skills"}, + * summary="Delete a Skill", + * description="Administrator only. Also removes any users_skills pivot rows referencing this skill.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Skill deleted"), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteSkillv2($id) + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $skill = Skills::findOrFail($id); + + if ($skill->delete()) { + UsersSkills::where('skill', $skill->id)->delete(); + } + + return response()->noContent(); + } + + private function validationRules($ignoreId = null): array + { + $allowedCategories = array_map('intval', array_keys(Fixometer::skillCategories())); + $uniqueRule = Rule::unique('skills', 'skill_name'); + if ($ignoreId !== null) { + $uniqueRule = $uniqueRule->ignore($ignoreId); + } + + return [ + 'skill_name' => ['required', 'string', 'max:255', $uniqueRule], + 'category' => ['required', 'integer', Rule::in($allowedCategories)], + 'description' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Controllers/API/StatsShareImageController.php b/app/Http/Controllers/API/StatsShareImageController.php new file mode 100644 index 0000000000..c23f72a85e --- /dev/null +++ b/app/Http/Controllers/API/StatsShareImageController.php @@ -0,0 +1,81 @@ + image + // lookup table can actually produce (ImpactRange{1-6}{Landscape,Square}- + // {increment}.png), plus WavyDividerLine.png, the one fixed decoration + // image painted onto every share image. This is a strict allowlist, not + // sanitisation: $filename comes straight from the URL, so anything not + // matching this pattern is rejected before the filesystem is touched at + // all - stripping '../' or similar would still leave room for a miss. + private const ALLOWED_PATTERN = '/^(ImpactRange[1-6](Landscape|Square)-\d+\.png|WavyDividerLine\.png)$/'; + + /** + * @OA\Get( + * path="/api/v2/stats/share-image/{filename}", + * operationId="getStatsShareImagev2", + * tags={"Devices"}, + * summary="Background/decoration image for the canvas-painted social-share image", + * description="Public. Unlike the rest of public/ (which sits outside config/cors.php's 'api/*' scope), this is reachable with CORS headers, so the Nuxt client can draw it onto a and export the result via toDataURL()/toBlob() without tainting the canvas. Serves only the fixed set of PNGs useStatsShareImage.js's CO2e->image lookup table can produce, plus WavyDividerLine.png - filename is checked against an allowlist pattern (not sanitised) before the filesystem is touched; anything else, or a path that resolves outside the images/stats directory, 404s.", + * @OA\Parameter( + * name="filename", + * in="path", + * required=true, + * @OA\Schema(type="string", pattern="^(ImpactRange[1-6](Landscape|Square)-\d+\.png|WavyDividerLine\.png)$"), + * example="ImpactRange2Landscape-10.png" + * ), + * @OA\Response( + * response=200, + * description="The PNG image", + * @OA\MediaType( + * mediaType="image/png", + * @OA\Schema(type="string", format="binary") + * ) + * ), + * @OA\Response(response=404, description="Unknown, invalid, or missing filename") + * ) + */ + public function shareImagev2(string $filename): Response + { + if (! preg_match(self::ALLOWED_PATTERN, $filename)) { + abort(404); + } + + $baseDir = realpath(public_path('images/stats')); + + if ($baseDir === false) { + abort(404); + } + + // realpath() collapses any '../' and resolves symlinks; it also + // returns false outright if the file doesn't exist, so a missing + // file 404s here too. Belt-and-braces on top of the allowlist above: + // confirm the fully-resolved path still sits inside $baseDir before + // serving anything from it. + $path = realpath($baseDir.DIRECTORY_SEPARATOR.$filename); + + if ($path === false || strncmp($path, $baseDir.DIRECTORY_SEPARATOR, strlen($baseDir) + 1) !== 0) { + abort(404); + } + + // A plain in-memory Response rather than Laravel's file()/ + // BinaryFileResponse helper: these images are at most a few hundred + // KB (nowhere near worth streaming), and BinaryFileResponse writes + // its body straight to the output buffer on send() rather than + // exposing it via getContent() - which makes it awkward to assert + // against in tests and gains nothing at this size. + return response(file_get_contents($path), 200, [ + 'Content-Type' => 'image/png', + // Every request for a given filename returns byte-identical + // content (the images are static, checked-in assets) - cache + // aggressively. + 'Cache-Control' => 'public, max-age=31536000, immutable', + ]); + } +} diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index 769aeb51ff..cd6e8fa76b 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -2,12 +2,31 @@ namespace App\Http\Controllers\API; -use Illuminate\Http\JsonResponse; +use App\Events\PasswordChanged; +use App\EventsUsers; +use App\Group; +use App\Helpers\Fixometer; +use App\Helpers\Geocoder; +use App\Helpers\LcaStats; +use App\Helpers\Tus; use App\Http\Controllers\Controller; +use App\Http\Resources\UserAdmin; +use App\Party; +use App\Permissions; +use App\Preferences; +use App\Role; use App\User; +use App\UserGroups; +use App\UsersSkills; use Auth; -use Illuminate\Http\Request; use Cache; +use DB; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UserController extends Controller { @@ -30,9 +49,16 @@ public static function changes(Request $request) $userAudits = self::getUserAudits($dateFrom); + // Batched, not one User::find() per audit row - same fix as + // GroupController::getGroupChanges and UserGroupsController::changes. + $users = User::withTrashed() + ->whereIn('id', $userAudits->pluck('auditable_id')->unique()->all()) + ->get() + ->keyBy('id'); + $userChanges = []; foreach ($userAudits as $userAudit) { - $user = User::withTrashed()->find($userAudit->auditable_id); + $user = $users->get($userAudit->auditable_id); if (! is_null($user) && $user->changesShouldPushToZapier()) { $userChanges[] = self::mapUserAndAuditToUserChange($user, $userAudit); } @@ -134,4 +160,2064 @@ public function notifications(Request $request, int $id): JsonResponse 'discourse' => $discourseNotifications ], 200); } + + /** + * @OA\Get( + * path="/api/v2/users/me/preferences", + * operationId="getMyEmailPreferencesv2", + * tags={"Users"}, + * summary="Get the authenticated user's email preferences", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="invites", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyEmailPreferencesv2(): JsonResponse + { + return response()->json([ + 'data' => $this->emailPreferencesData(Auth::user()), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}/preferences", + * operationId="getUserPreferencesv2", + * tags={"Users"}, + * summary="Administrator (or self): get a user's email preferences", + * description="Id-scoped mirror of GET /users/me/preferences, so an admin edit form can be pre-filled with the target's current value before PATCHing it. Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="invites", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getUserPreferencesv2(int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->emailPreferencesData($target), + ]); + } + + /** + * Shared body of getMyEmailPreferencesv2/getUserPreferencesv2. + */ + private function emailPreferencesData(User $user): array + { + return [ + 'invites' => (bool) $user->invites, + ]; + } + + /** + * @OA\Patch( + * path="/api/v2/users/me/preferences", + * operationId="updateMyEmailPreferencesv2", + * tags={"Users"}, + * summary="Update the authenticated user's email preferences", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="invites", type="boolean") + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="invites", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMyEmailPreferencesv2(Request $request): JsonResponse + { + return response()->json([ + 'data' => $this->applyEmailPreferencesUpdate($request, Auth::user()), + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/preferences", + * operationId="updateUserPreferencesv2", + * tags={"Users"}, + * summary="Administrator (or self): update a user's email preferences", + * description="Id-scoped mirror of PATCH /users/me/preferences. Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="invites", type="boolean") + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="invites", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateUserPreferencesv2(Request $request, int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->applyEmailPreferencesUpdate($request, $target), + ]); + } + + /** + * Shared body of updateMyEmailPreferencesv2/updateUserPreferencesv2. + */ + private function applyEmailPreferencesUpdate(Request $request, User $user): array + { + $validated = $request->validate([ + 'invites' => 'required|boolean', + ]); + + $user->invites = $validated['invites'] ? 1 : 0; + $user->save(); + + return $this->emailPreferencesData($user); + } + + /** + * @OA\Post( + * path="/api/v2/users/me/onboarding-complete", + * operationId="onboardingCompletev2", + * tags={"Users"}, + * summary="Mark the post-registration onboarding modal as seen", + * description="Port of the legacy GET /user/onboarding-complete (UserController::getOnboardingComplete): bumps number_of_logins to at least 2 so GET /api/v2/session's flags.onboarding is false from the next call onwards.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Onboarding dismissed", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="onboarding", type="boolean") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required") + * ) + */ + public function onboardingCompletev2(): JsonResponse + { + $user = Auth::user(); + + if ($user->number_of_logins < 2) { + $user->number_of_logins += 1; + $user->save(); + } + + return response()->json(['data' => ['onboarding' => false]]); + } + + /** + * @OA\Get( + * path="/api/v2/users/me/groups", + * operationId="getMyGroupsv2", + * tags={"Users"}, + * summary="All of the authenticated user's group memberships", + * description="Unlike GET /api/v2/dashboard's your_groups (capped at 5), this returns every group the user has a users_groups pivot row for.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="role", type="integer"), + * @OA\Property(property="archived", type="boolean"), + * @OA\Property(property="image_url", type="string", nullable=true), + * @OA\Property(property="location", type="object", nullable=true, + * @OA\Property(property="location", type="string"), + * @OA\Property(property="country", type="string", nullable=true) + * ), + * @OA\Property(property="hosts", type="integer"), + * @OA\Property(property="restarters", type="integer"), + * @OA\Property(property="next_event", type="object", nullable=true, + * @OA\Property(property="start", type="string") + * ) + * ))) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyGroupsv2(): JsonResponse + { + $user = Auth::user(); + + // location/country and the host/restarter counts are selected and + // counted here rather than left to the caller: the /group list renders + // develop's full GroupsTable (name / location / hosts / restarters / + // next event), and this is the only uncapped source of "groups I am + // in". Without them the client had to switch four columns off, which + // read as a design choice rather than the missing payload it was. + // withCount avoids an N+1 over allHosts/allRestarters. + $groups = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups') + ->where('users_groups.user', $user->id) + ->whereNull('users_groups.deleted_at') + ->orderBy('groups.name', 'ASC') + ->groupBy('groups.idgroups', 'groups.name', 'users_groups.role', 'groups.archived_at', 'groups.location', 'groups.country_code') + ->select(['groups.idgroups', 'groups.name', 'users_groups.role', 'groups.archived_at', 'groups.location', 'groups.country_code']) + ->withCount(['allHosts', 'allRestarters']) + ->with('groupImage.image') + ->get(); + + // Next upcoming event per group, in ONE query. getNextUpcomingEvent() + // is per-group, so calling it inside the map below would be an N+1 - + // which is what the first version of this did. + $nextEvents = \App\Party::whereIn('group', $groups->pluck('idgroups')) + ->where('approved', true) + ->where('event_start_utc', '>=', date('Y-m-d H:i:s')) + ->orderBy('event_start_utc', 'asc') + ->get(['group', 'event_start_utc']) + ->groupBy('group') + ->map(fn ($rows) => $rows->first()->event_start_utc); + + return response()->json([ + 'data' => $groups->map(fn ($group) => [ + 'id' => $group->idgroups, + 'name' => $group->name, + 'role' => (int) $group->role, + 'archived' => ! is_null($group->archived_at), + 'image_url' => $group->realImageUrl(), + 'location' => $group->location ? [ + 'location' => $group->location, + 'country' => \App\Helpers\Fixometer::getCountryFromCountryCode($group->country_code), + ] : null, + 'hosts' => (int) $group->all_hosts_count, + 'restarters' => (int) $group->all_restarters_count, + 'next_event' => isset($nextEvents[$group->idgroups]) ? [ + 'start' => $nextEvents[$group->idgroups], + ] : null, + ])->values()->all(), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/me/events", + * operationId="getMyEventsv2", + * tags={"Users","Events"}, + * summary="Events relevant to the authenticated user", + * description="Replacement basis for the dead /party (mine) route (PartyController::index()'s no-group_id branch, which has no API endpoint today - only a server-rendered prop). Returns the union of: events the user hosts/attends/belongs to the group of, nearby upcoming events if the user has a location, and other approved upcoming events - each tagged nearby/all as the Blade view does.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="title", type="string", nullable=true), + * @OA\Property(property="start", type="string", format="date-time"), + * @OA\Property(property="end", type="string", format="date-time"), + * @OA\Property(property="timezone", type="string", nullable=true), + * @OA\Property(property="online", type="boolean"), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="approved", type="boolean"), + * @OA\Property(property="attending", type="boolean"), + * @OA\Property(property="nearby", type="boolean", description="True for a nearby-upcoming event not already in the user's own list"), + * @OA\Property(property="all", type="boolean", description="True for an event included only because it's nearby or generally upcoming, not because the user hosts/attends/belongs to its group"), + * @OA\Property(property="group", type="object", nullable=true, + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="country", type="string", description="The host group's free-form country name (Fixometer::getCountryFromCountryCode of its country_code), empty string if not set.") + * ), + * @OA\Property(property="stats", type="object", description="Party::getEventStats() - the same shape as the stats block on GET /api/v2/events/{id}. participants/volunteers/invited/hours_volunteered are always populated; the device/waste/co2 counters are only non-zero once the event has started or finished.", + * @OA\Property(property="co2_powered", type="number"), + * @OA\Property(property="co2_unpowered", type="number"), + * @OA\Property(property="co2_total", type="number"), + * @OA\Property(property="waste_powered", type="number"), + * @OA\Property(property="waste_unpowered", type="number"), + * @OA\Property(property="waste_total", type="number"), + * @OA\Property(property="fixed_devices", type="number"), + * @OA\Property(property="fixed_powered", type="number"), + * @OA\Property(property="fixed_unpowered", type="number"), + * @OA\Property(property="repairable_devices", type="number"), + * @OA\Property(property="dead_devices", type="number"), + * @OA\Property(property="unknown_repair_status", type="number"), + * @OA\Property(property="devices_powered", type="number"), + * @OA\Property(property="devices_unpowered", type="number"), + * @OA\Property(property="no_weight_powered", type="number"), + * @OA\Property(property="no_weight_unpowered", type="number"), + * @OA\Property(property="participants", type="number"), + * @OA\Property(property="volunteers", type="number"), + * @OA\Property(property="hours_volunteered", type="number"), + * @OA\Property(property="invited", type="number") + * ) + * ))) + * ), + * @OA\Response(response=401, description="Unauthenticated") + * ) + */ + public function getMyEventsv2(Request $request): JsonResponse + { + $user = $request->user(); + + // Explicit $userids everywhere below - these scopes fall back to Auth::user() (the + // default 'web' guard) when passed null, which is not necessarily who $request->user() + // resolved via the sanctum/api guards. + $attending = EventsUsers::where('user', $user->id)->where('status', '1')->pluck('event')->toArray(); + + // Computed once and passed down to every getEventStats() call below (mirrors + // Group::bulkGroupStats()/EventController::getEventsByUsersNetworks() "to speed things + // up a bit") rather than re-reading the same env vars per event. + $eEmissionRatio = LcaStats::getEmissionRatioPowered(); + $uEmissionratio = LcaStats::getEmissionRatioUnpowered(); + + $events = []; + $seenIds = []; + + // allDevices is eager-loaded because this list mixes past/in-progress/future events - + // getEventStats() touches it for any event that has started, so without this each such + // event would trigger its own device query (N+1). withCount('allInvited') avoids the + // equivalent N+1 for the invited tally (see Party::getEventStats()'s all_invited_count + // comment). + foreach ( + Party::forUser([$user->id]) + ->with(['theGroup.groupImage.image', 'allDevices']) + ->withCount('allInvited') + ->reorder()->orderBy('event_start_utc', 'DESC')->get() as $event + ) { + $events[] = self::shapeMyEvent($event, $attending, $eEmissionRatio, $uEmissionratio); + $seenIds[] = $event->idevents; + } + + if (! is_null($user->latitude) && ! is_null($user->longitude)) { + // Strictly upcoming (event_start_utc >= now), so allDevices is never touched by + // getEventStats() here - no need to eager-load it, only the invited count. + $nearbyEvents = Party::upcomingEventsInUserArea($user) + ->with(['theGroup.groupImage.image']) + ->withCount('allInvited') + ->whereNotIn('idevents', $seenIds) + ->get(); + + foreach ($nearbyEvents as $event) { + if (Fixometer::userHasViewPartyPermission($event->idevents, $user->id)) { + $row = self::shapeMyEvent($event, $attending, $eEmissionRatio, $uEmissionratio); + $row['nearby'] = true; + $row['all'] = true; + $events[] = $row; + $seenIds[] = $event->idevents; + } + } + } + + // Strictly future too (event_start_utc > now) - same reasoning, no allDevices needed. + $otherUpcoming = Party::with(['theGroup.networks', 'theGroup.groupImage.image'])->future() + ->withCount('allInvited') + ->whereNotIn('idevents', $seenIds) + ->get(); + + foreach ($otherUpcoming as $event) { + if (Fixometer::userHasViewPartyPermission($event->idevents, $user->id, $event)) { + $row = self::shapeMyEvent($event, $attending, $eEmissionRatio, $uEmissionratio); + $row['all'] = true; + $events[] = $row; + } + } + + return response()->json(['data' => $events]); + } + + private static function shapeMyEvent(Party $event, array $attending, $eEmissionRatio = null, $uEmissionratio = null): array + { + $group = $event->theGroup; + + return [ + 'id' => $event->idevents, + 'title' => $event->venue ?? $event->location, + 'start' => $event->event_start_utc, + 'end' => $event->event_end_utc, + 'timezone' => $event->timezone, + 'online' => (bool) $event->online, + 'location' => $event->location, + 'approved' => (bool) $event->approved, + 'attending' => in_array($event->idevents, $attending), + 'nearby' => false, + 'all' => false, + 'group' => $group ? [ + 'id' => $group->idgroups, + 'name' => $group->name, + 'country' => Fixometer::getCountryFromCountryCode($group->country_code), + ] : null, + // Same shape as the "stats" block on GET /api/v2/events/{id} (App\Http\Resources\Party) - + // party/view/[id].vue already reads event.stats.{participants,volunteers,waste_total, + // co2_total,fixed_devices,repairable_devices,dead_devices,...} unconditionally, gating + // what it shows on finished/upcoming client-side. Do the same here rather than shipping + // two different per-event shapes. + 'stats' => $event->getEventStats($eEmissionRatio, $uEmissionratio), + ]; + } + + /** + * @OA\Get( + * path="/api/v2/users/me/calendars", + * operationId="getMyCalendarsv2", + * tags={"Users"}, + * summary="Get the authenticated user's calendar subscription URLs", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="user_url", type="string"), + * @OA\Property(property="groups", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="url", type="string") + * )), + * @OA\Property(property="is_admin", type="boolean"), + * @OA\Property(property="admin_all_events_url", type="string", nullable=true), + * @OA\Property(property="group_areas", type="array", @OA\Items(type="string")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyCalendarsv2(): JsonResponse + { + $user = Auth::user(); + + $groups = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups') + ->join('events', 'events.group', '=', 'groups.idgroups') + ->where('users_groups.user', $user->id) + ->select('groups.idgroups', 'groups.name') + ->groupBy('groups.idgroups', 'groups.name') + ->orderBy('groups.idgroups', 'ASC') + ->get(); + + $groupRows = $groups->map(function ($g) { + return [ + 'id' => (int) $g->idgroups, + 'name' => $g->name, + 'url' => url('/calendar/group/' . $g->idgroups), + ]; + })->all(); + + $isAdmin = Fixometer::hasRole($user, 'Administrator'); + $calendarHash = config('restarters.calendar_hash'); + $adminAllEventsUrl = $isAdmin && $calendarHash + ? url('/calendar/all-events/' . $calendarHash . '/') + : null; + + $groupAreas = Group::whereNotNull('area') + ->groupBy('area') + ->pluck('area') + ->toArray(); + + return response()->json([ + 'data' => [ + 'user_url' => url('/calendar/user/' . $user->calendar_hash), + 'groups' => $groupRows, + 'is_admin' => $isAdmin, + 'admin_all_events_url' => $adminAllEventsUrl, + 'group_areas' => $groupAreas, + ], + ]); + } + + private function repairDirRoleNames(): array + { + return [ + Role::REPAIR_DIRECTORY_NONE => 'profile.repair_dir_none', + Role::REPAIR_DIRECTORY_EDITOR => 'profile.repair_dir_editor', + Role::REPAIR_DIRECTORY_REGIONAL_ADMIN => 'profile.repair_dir_regional_admin', + Role::REPAIR_DIRECTORY_SUPERADMIN => 'profile.repair_dir_superadmin', + ]; + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}/repair-directory-options", + * operationId="getRepairDirOptionsv2", + * tags={"Users"}, + * summary="List Repair Directory role options available for the target user", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="current", type="integer"), + * @OA\Property(property="options", type="array", @OA\Items( + * @OA\Property(property="value", type="integer"), + * @OA\Property(property="key", type="string"), + * @OA\Property(property="selected", type="boolean"), + * @OA\Property(property="disabled", type="boolean") + * )) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getRepairDirOptionsv2(int $id): JsonResponse + { + $perp = Auth::user(); + $victim = User::find($id); + if (!$victim) { + throw new NotFoundHttpException(); + } + + $options = []; + foreach ($this->repairDirRoleNames() as $value => $key) { + $options[] = [ + 'value' => $value, + 'key' => $key, + 'selected' => $victim->repairdir_role() === $value, + 'disabled' => !$perp->can('changeRepairDirRole', [$victim, $value]), + ]; + } + + return response()->json([ + 'data' => [ + 'current' => $victim->repairdir_role(), + 'options' => $options, + ], + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/repair-directory-role", + * operationId="updateRepairDirRolev2", + * tags={"Users"}, + * summary="Update a user's Repair Directory role (policy-gated)", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody(required=true, + * @OA\JsonContent(@OA\Property(property="role", type="integer")) + * ), + * @OA\Response(response=200, description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="role", type="integer") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateRepairDirRolev2(Request $request, int $id): JsonResponse + { + $perp = Auth::user(); + $victim = User::find($id); + if (!$victim) { + throw new NotFoundHttpException(); + } + + $validated = $request->validate([ + 'role' => 'required|integer|in:' . implode(',', array_keys($this->repairDirRoleNames())), + ]); + + if (!$perp->can('changeRepairDirRole', [$victim, $validated['role']])) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $victim->repairdir_role = $validated['role']; + $victim->save(); + + return response()->json([ + 'data' => [ + 'role' => $victim->repairdir_role(), + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/me/language", + * operationId="getMyLanguagev2", + * tags={"Users"}, + * summary="Get the authenticated user's preferred language", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="language", type="string", nullable=true), + * @OA\Property(property="supported", type="array", @OA\Items( + * @OA\Property(property="code", type="string"), + * @OA\Property(property="native", type="string") + * )) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyLanguagev2(): JsonResponse + { + $supported = []; + foreach (\LaravelLocalization::getSupportedLocales() as $code => $props) { + $supported[] = [ + 'code' => $code, + 'native' => $props['native'] ?? $code, + ]; + } + + return response()->json([ + 'data' => [ + 'language' => Auth::user()->language, + 'supported' => $supported, + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/me/notifications", + * operationId="getMyNotificationsv2", + * tags={"Users"}, + * summary="List the authenticated user's in-app notifications", + * description="Paginated list of the user's Restarters (in-app) notifications, replacing the old /profile/notifications page.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Notifications", + * @OA\JsonContent( + * @OA\Property(property="data", type="array", @OA\Items( + * @OA\Property(property="id", type="string"), + * @OA\Property(property="type", type="string", description="The notification's class basename, e.g. NewGroupMember"), + * @OA\Property(property="title", type="string", nullable=true), + * @OA\Property(property="name", type="string", nullable=true), + * @OA\Property(property="url", type="string", nullable=true), + * @OA\Property(property="read", type="boolean"), + * @OA\Property(property="created_at", type="string", format="date-time", nullable=true) + * )), + * @OA\Property(property="meta", type="object", + * @OA\Property(property="current_page", type="integer"), + * @OA\Property(property="last_page", type="integer"), + * @OA\Property(property="total", type="integer"), + * @OA\Property(property="unread", type="integer") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyNotificationsv2(Request $request): JsonResponse + { + $user = Auth::user(); + $notifications = $user->notifications()->paginate(20); + + return response()->json([ + 'data' => collect($notifications->items())->map(function ($n) { + $data = is_array($n->data) ? $n->data : (array) $n->data; + + return [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $data['title'] ?? null, + 'name' => $data['name'] ?? null, + 'url' => $data['url'] ?? null, + 'read' => $n->read_at !== null, + 'created_at' => optional($n->created_at)->toIso8601String(), + ]; + })->all(), + 'meta' => [ + 'current_page' => $notifications->currentPage(), + 'last_page' => $notifications->lastPage(), + 'total' => $notifications->total(), + 'unread' => $user->unreadNotifications()->count(), + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/users/me/notifications/read", + * operationId="markMyNotificationsReadv2", + * tags={"Users"}, + * summary="Mark the user's notifications as read", + * description="Marks a single notification (by id) or all of them as read.", + * security={{"apiToken":{}}}, + * @OA\RequestBody(required=false, @OA\JsonContent( + * @OA\Property(property="id", type="string", description="Notification id; omit to mark all as read"), + * )), + * @OA\Response( + * response=200, + * description="Marked read", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="unread", type="integer") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required") + * ) + */ + public function markMyNotificationsReadv2(Request $request): JsonResponse + { + $user = Auth::user(); + $id = $request->input('id'); + + if ($id) { + $notification = $user->notifications()->where('id', $id)->first(); + if ($notification) { + $notification->markAsRead(); + } + } else { + $user->unreadNotifications->markAsRead(); + } + + return response()->json(['data' => ['unread' => $user->unreadNotifications()->count()]]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/me/language", + * operationId="updateMyLanguagev2", + * tags={"Users"}, + * summary="Update the authenticated user's preferred language", + * security={{"apiToken":{}}}, + * @OA\RequestBody(required=true, @OA\JsonContent( + * @OA\Property(property="language", type="string") + * )), + * @OA\Response(response=200, description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", type="object", + * @OA\Property(property="language", type="string") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMyLanguagev2(Request $request): JsonResponse + { + $supportedCodes = array_keys(\LaravelLocalization::getSupportedLocales()); + $validated = $request->validate([ + 'language' => 'required|string|in:' . implode(',', $supportedCodes), + ]); + + $user = Auth::user(); + $user->language = $validated['language']; + $user->save(); + + session()->put('locale', $validated['language']); + \LaravelLocalization::setLocale($validated['language']); + \App::setLocale($validated['language']); + event(new \App\Events\UserLanguageUpdated($user)); + + return response()->json([ + 'data' => [ + 'language' => $user->language, + ], + ]); + } + + private function profileCountryOptions(): array + { + $options = []; + foreach (Fixometer::getAllCountries() as $code => $name) { + $options[] = [ + 'code' => $code, + 'name' => $name, + ]; + } + + return $options; + } + + private function profileAgeOptions(): array + { + // Includes a leading '' entry (matching the legacy blade select), so the + // dropdown can show no selection until the user picks a year of birth. + // Cast to strings for a consistent JSON type (option values are strings + // in the HTML select either way). + return array_map('strval', array_values(Fixometer::allAges())); + } + + /** + * @OA\Get( + * path="/api/v2/users/me/profile", + * operationId="getMyProfilev2", + * tags={"Users"}, + * summary="Get the authenticated user's profile info", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="country_code", type="string", nullable=true), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="age", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true), + * @OA\Property(property="countries", type="array", @OA\Items( + * @OA\Property(property="code", type="string"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="ages", type="array", @OA\Items(type="string")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMyProfilev2(): JsonResponse + { + return response()->json([ + 'data' => $this->profileData(Auth::user()), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}/profile", + * operationId="getUserProfilev2", + * tags={"Users"}, + * summary="Administrator (or self): get a user's profile info", + * description="Id-scoped mirror of GET /users/me/profile, so an admin edit form can be pre-filled with the target's current values before PATCHing them. Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="country_code", type="string", nullable=true), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="age", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true), + * @OA\Property(property="countries", type="array", @OA\Items( + * @OA\Property(property="code", type="string"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="ages", type="array", @OA\Items(type="string")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getUserProfilev2(int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->profileData($target), + ]); + } + + /** + * Shared body of getMyProfilev2/getUserProfilev2. Deliberately NOT shared with + * applyProfileUpdate()'s return value - the PATCH response (see updateMyProfilev2's + * @OA doc) omits the countries/ages option lists that only a GET-for-editing needs. + */ + private function profileData(User $user): array + { + return [ + 'name' => $user->name, + 'email' => $user->email, + 'country_code' => $user->country_code, + 'location' => $user->location, + // The geocoded point behind `location`, for the groups map's + // distance column (anchored to the user's own coordinates). + 'lat' => $user->latitude === null ? null : (float) $user->latitude, + 'lng' => $user->longitude === null ? null : (float) $user->longitude, + 'age' => $user->age, + 'gender' => $user->gender, + 'biography' => $user->biography, + 'countries' => $this->profileCountryOptions(), + 'ages' => $this->profileAgeOptions(), + ]; + } + + /** + * @OA\Patch( + * path="/api/v2/users/me/profile", + * operationId="updateMyProfilev2", + * tags={"Users"}, + * summary="Update the authenticated user's profile info", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name", "email", "age", "country"}, + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="age", type="string"), + * @OA\Property(property="country", type="string"), + * @OA\Property(property="townCity", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="country_code", type="string", nullable=true), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="age", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMyProfilev2(Request $request, Geocoder $geocoder): JsonResponse + { + return response()->json([ + 'data' => $this->applyProfileUpdate($request, Auth::user(), $geocoder), + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/profile", + * operationId="updateUserProfilev2", + * tags={"Users"}, + * summary="Administrator (or self): update a user's profile info", + * description="Id-scoped mirror of PATCH /users/me/profile - identical validation and persistence, targeting the resolved user instead of Auth::user(). Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name", "email", "age", "country"}, + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="age", type="string"), + * @OA\Property(property="country", type="string"), + * @OA\Property(property="townCity", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="country_code", type="string", nullable=true), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="age", type="string", nullable=true), + * @OA\Property(property="gender", type="string", nullable=true), + * @OA\Property(property="biography", type="string", nullable=true) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateUserProfilev2(Request $request, Geocoder $geocoder, int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->applyProfileUpdate($request, $target, $geocoder), + ]); + } + + /** + * Shared body of updateMyProfilev2/updateUserProfilev2 so the self and admin paths can + * never drift. $user is Auth::user() for the self route, or the resolved+authorised + * target for the id-scoped admin route. + */ + private function applyProfileUpdate(Request $request, User $user, Geocoder $geocoder): array + { + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255', + 'age' => 'required', + 'country' => 'required', + ]); + + $user->update([ + 'name' => $request->input('name'), + 'email' => $request->input('email'), + 'country_code' => $request->input('country'), + 'location' => $request->input('townCity'), + 'age' => $request->input('age'), + 'gender' => $request->input('gender'), + 'biography' => $request->input('biography'), + ]); + + $user = $user->fresh(); + + if (! empty($user->location)) { + $geocoded = $geocoder->geocode("{$user->location}, " . Fixometer::getCountryFromCountryCode($user->country_code)); + if (! empty($geocoded)) { + $user->latitude = $geocoded['latitude']; + $user->longitude = $geocoded['longitude']; + } else { + $user->latitude = null; + $user->longitude = null; + } + } else { + $user->latitude = null; + $user->longitude = null; + } + + $user->save(); + + return [ + 'name' => $user->name, + 'email' => $user->email, + 'country_code' => $user->country_code, + 'location' => $user->location, + 'age' => $user->age, + 'gender' => $user->gender, + 'biography' => $user->biography, + ]; + } + + /** + * @OA\Get( + * path="/api/v2/users/me/skills", + * operationId="getMySkillsv2", + * tags={"Users"}, + * summary="Get the repair-skills catalogue and the authenticated user's current selection", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="categories", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="label", type="string"), + * @OA\Property(property="skills", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )) + * )), + * @OA\Property(property="selected", type="array", @OA\Items(type="integer")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ + public function getMySkillsv2(): JsonResponse + { + return response()->json([ + 'data' => $this->skillsData(Auth::user()), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}/skills", + * operationId="getUserSkillsv2", + * tags={"Users"}, + * summary="Administrator (or self): get the repair-skills catalogue and a user's current selection", + * description="Id-scoped mirror of GET /users/me/skills, so an admin edit form can be pre-filled with the target's current selection before PATCHing it. Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="categories", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="label", type="string"), + * @OA\Property(property="skills", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )) + * )), + * @OA\Property(property="selected", type="array", @OA\Items(type="integer")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getUserSkillsv2(int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->skillsData($target), + ]); + } + + /** + * Shared body of getMySkillsv2/getUserSkillsv2. + */ + private function skillsData(User $user): array + { + $selected = UsersSkills::where('user', $user->id) + ->pluck('skill') + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + $allSkills = Fixometer::allSkills(); + + $categories = []; + foreach (Fixometer::skillCategories() as $key => $label) { + $skillsForCategory = []; + if (isset($allSkills[$key])) { + foreach ($allSkills[$key] as $skill) { + $skillsForCategory[] = [ + 'id' => (int) $skill->id, + 'name' => $skill->skill_name, + ]; + } + } + $categories[] = [ + 'id' => (int) $key, + 'label' => $label, + 'skills' => $skillsForCategory, + ]; + } + + return [ + 'categories' => $categories, + 'selected' => $selected, + ]; + } + + /** + * @OA\Patch( + * path="/api/v2/users/me/skills", + * operationId="updateMySkillsv2", + * tags={"Users"}, + * summary="Replace the authenticated user's repair skills", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="tags", type="array", @OA\Items(type="integer")) + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="tags", type="array", @OA\Items(type="integer")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMySkillsv2(Request $request): JsonResponse + { + return response()->json([ + 'data' => $this->applySkillsUpdate($request, Auth::user()), + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/skills", + * operationId="updateUserSkillsv2", + * tags={"Users"}, + * summary="Administrator (or self): replace a user's repair skills", + * description="Id-scoped mirror of PATCH /users/me/skills. Authorised via UserPolicy::update (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="tags", type="array", @OA\Items(type="integer")) + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="tags", type="array", @OA\Items(type="integer")) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateUserSkillsv2(Request $request, int $id): JsonResponse + { + $target = User::findOrFail($id); + $this->authorize('update', $target); + + return response()->json([ + 'data' => $this->applySkillsUpdate($request, $target), + ]); + } + + /** + * Shared body of updateMySkillsv2/updateUserSkillsv2. + */ + private function applySkillsUpdate(Request $request, User $user): array + { + $validated = $request->validate([ + 'tags' => 'nullable|array', + 'tags.*' => 'integer', + ]); + + $skills = $validated['tags'] ?? []; + + $user->skillsold()->sync($skills); + $user->refresh(); + + $roleBasedOnSkills = Fixometer::skillsDetermineRole($skills); + + if ($roleBasedOnSkills == Role::HOST) { + $user->convertToHost(); + } + + $currentSkillIds = UsersSkills::where('user', $user->id) + ->pluck('skill') + ->map(fn ($id) => (int) $id) + ->values() + ->all(); + + return [ + 'tags' => $currentSkillIds, + ]; + } + + /** + * @OA\Patch( + * path="/api/v2/users/me/password", + * operationId="updateMyPasswordv2", + * tags={"Users"}, + * summary="Change the authenticated user's password", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"current_password", "new_password", "new_password_confirmation"}, + * @OA\Property(property="current_password", type="string"), + * @OA\Property(property="new_password", type="string"), + * @OA\Property(property="new_password_confirmation", type="string") + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="success", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMyPasswordv2(Request $request): JsonResponse + { + // This endpoint always operates on the authenticated user - there is no id + // parameter, so there is no possibility of targeting another user's password. + $user = Auth::user(); + $this->applyPasswordUpdate($request, $user, $user); + + return response()->json([ + 'data' => [ + 'success' => true, + ], + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/password", + * operationId="updateUserPasswordv2", + * tags={"Users"}, + * summary="Administrator (or self): change a user's password", + * description="Id-scoped mirror of PATCH /users/me/password. Authorised via UserPolicy::update (self-or-Administrator). When an Administrator resets ANOTHER user's password, current_password is not required or checked (matching the legacy admin edit-user form, which lets admins set a new password directly); when acting on your own id, current_password is still required, exactly as the self route.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"new_password", "new_password_confirmation"}, + * @OA\Property(property="current_password", type="string", description="Required only when the target id is the acting user's own id"), + * @OA\Property(property="new_password", type="string"), + * @OA\Property(property="new_password_confirmation", type="string") + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="success", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateUserPasswordv2(Request $request, int $id): JsonResponse + { + $actor = Auth::user(); + $target = User::findOrFail($id); + $this->authorize('update', $target); + + $this->applyPasswordUpdate($request, $actor, $target); + + return response()->json([ + 'data' => [ + 'success' => true, + ], + ]); + } + + /** + * Shared body of updateMyPasswordv2/updateUserPasswordv2. $actor is who is making the + * request, $target is whose password is being changed - they are the same User instance + * on the self route. current_password is only required/checked when $actor === $target; + * an Administrator resetting another user's password doesn't know (and shouldn't need) + * the target's current password. + */ + private function applyPasswordUpdate(Request $request, User $actor, User $target): void + { + $isSelf = $actor->id == $target->id; + + $rules = [ + 'new_password' => 'required|string', + 'new_password_confirmation' => 'required|string', + ]; + if ($isSelf) { + $rules['current_password'] = 'required|string'; + } + + $validated = $request->validate($rules); + + if ($validated['new_password'] !== $validated['new_password_confirmation']) { + throw ValidationException::withMessages([ + 'new_password_confirmation' => [__('profile.password_new_mismatch')], + ]); + } + + if ($isSelf && ! Hash::check($validated['current_password'], $target->password)) { + throw ValidationException::withMessages([ + 'current_password' => [__('profile.password_old_mismatch')], + ]); + } + + $oldPassword = $target->password; + $target->setPassword(Hash::make($validated['new_password'])); + $target->save(); + + $target->update([ + 'recovery' => Fixometer::generateHash(), + 'recovery_expires' => strftime('%Y-%m-%d %X', time() + (24 * 60 * 60)), + ]); + + event(new PasswordChanged($target, $oldPassword)); + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}/admin-settings", + * operationId="getAdminSettingsv2", + * tags={"Users"}, + * summary="Administrator-only: get a user's role, groups, preferences, permissions and the available options", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="role", type="integer", nullable=true), + * @OA\Property(property="assigned_groups", type="array", @OA\Items(type="integer")), + * @OA\Property(property="preferences", type="array", @OA\Items(type="integer")), + * @OA\Property(property="permissions", type="array", @OA\Items(type="integer")), + * @OA\Property(property="roles", type="array", @OA\Items( + * @OA\Property(property="value", type="integer"), + * @OA\Property(property="label", type="string") + * )), + * @OA\Property(property="groups", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="preferences_options", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="purpose", type="string", nullable=true) + * )), + * @OA\Property(property="permissions_options", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="purpose", type="string", nullable=true) + * )) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function getAdminSettingsv2(int $id): JsonResponse + { + if (! Auth::user()->hasRole('Administrator')) { + abort(403); + } + + $user = User::find($id); + if (! $user) { + throw new NotFoundHttpException(); + } + + $roles = Role::all()->map(fn ($r) => [ + 'value' => (int) $r->idroles, + 'label' => $r->role, + ])->values()->all(); + + $groups = Group::orderBy('name')->get()->map(fn ($g) => [ + 'id' => (int) $g->idgroups, + 'name' => $g->name, + ])->values()->all(); + + $preferencesOptions = Preferences::all()->map(fn ($p) => [ + 'id' => (int) $p->id, + 'name' => $p->name, + 'purpose' => $p->purpose, + ])->values()->all(); + + $permissionsOptions = Permissions::all()->map(fn ($p) => [ + 'id' => (int) $p->idpermissions, + 'name' => $p->permission, + 'purpose' => $p->purpose, + ])->values()->all(); + + return response()->json([ + 'data' => [ + 'role' => $user->role, + 'assigned_groups' => $user->groups()->pluck('idgroups')->map(fn ($v) => (int) $v)->values()->all(), + 'preferences' => DB::table('users_preferences')->where('user_id', $user->id)->pluck('preference_id')->map(fn ($v) => (int) $v)->values()->all(), + 'permissions' => DB::table('users_permissions')->where('user_id', $user->id)->pluck('permission_id')->map(fn ($v) => (int) $v)->values()->all(), + 'roles' => $roles, + 'groups' => $groups, + 'preferences_options' => $preferencesOptions, + 'permissions_options' => $permissionsOptions, + ], + ]); + } + + /** + * @OA\Patch( + * path="/api/v2/users/{id}/admin-settings", + * operationId="updateAdminSettingsv2", + * tags={"Users"}, + * summary="Administrator-only: update a user's role, groups, preferences and permissions", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"user_role"}, + * @OA\Property(property="user_role", type="integer"), + * @OA\Property(property="assigned_groups", type="array", @OA\Items(type="integer")), + * @OA\Property(property="preferences", type="array", @OA\Items(type="integer")), + * @OA\Property(property="permissions", type="array", @OA\Items(type="integer")) + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="role", type="integer") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateAdminSettingsv2(Request $request, int $id): JsonResponse + { + // Administrator-only. Matches the web handler's exact check (postAdminEdit) - + // this is a critical privilege-escalation guard, so it is deliberately kept as + // simple and explicit as the code it replaces. + if (! Auth::user()->hasRole('Administrator')) { + abort(403); + } + + $user = User::find($id); + if (! $user) { + throw new NotFoundHttpException(); + } + + $validated = $request->validate([ + 'user_role' => 'required|integer', + 'assigned_groups' => 'nullable|array', + 'assigned_groups.*' => 'integer', + 'preferences' => 'nullable|array', + 'preferences.*' => 'integer', + 'permissions' => 'nullable|array', + 'permissions.*' => 'integer', + ]); + + $groups = $validated['assigned_groups'] ?? []; + $preferences = $validated['preferences'] ?? []; + $permissions = $validated['permissions'] ?? []; + + $oldRole = $user->role; + + // Set role directly - role is not mass-assignable (security: see UserController::postAdminEdit). + $user->role = $validated['user_role']; + $user->save(); + + // If we are demoting from NetworkCoordinator, remove them from the list of coordinators for + // any networks they are currently coordinating. + if ($oldRole == Role::NETWORK_COORDINATOR && ($user->role == Role::HOST || $user->role == Role::RESTARTER)) { + $user->networks()->detach(); + } + + // The user may have previously been removed from a group, which will mean they have an entry in + // users_groups with deleted_at set. Restore it so that sync() then works - sync() doesn't handle + // soft deletes itself. + foreach ($groups as $idgroups) { + $inGroup = UserGroups::where('user', $id)->where('group', $idgroups)->withTrashed()->first(); + + if ($inGroup && $inGroup->trashed()) { + $inGroup->restore(); + } + } + + $user->groups()->sync($groups); + $user->preferences()->sync($preferences); + $user->permissions()->sync($permissions); + + return response()->json([ + 'data' => [ + 'role' => $user->fresh()->role, + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/users/me/photo", + * operationId="updateMyPhotov2", + * tags={"Users"}, + * summary="Attach a completed tus upload as the authenticated user's profile photo", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"upload_key"}, + * @OA\Property(property="upload_key", type="string", description="The tus upload key/id returned by the tus server once the upload completed") + * ) + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="path", type="string") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function updateMyPhotov2(Request $request): JsonResponse + { + // This endpoint always operates on Auth::user() - there is no id parameter, so + // there is no possibility of uploading a photo on behalf of another user. + $user = Auth::user(); + + $validated = $request->validate([ + 'upload_key' => 'required|string', + ]); + + $cache = Tus::buildCache(); + $meta = $cache->get($validated['upload_key']); + + $filePath = $meta['file_path'] ?? null; + + if (! $meta || ! $filePath || ! is_file($filePath)) { + throw ValidationException::withMessages([ + 'upload_key' => [__('profile.picture_error')], + ]); + } + + // Confirm the tus upload actually finished (offset === size), not just started. + if (($meta['offset'] ?? null) !== ($meta['size'] ?? null)) { + throw ValidationException::withMessages([ + 'upload_key' => [__('profile.picture_error')], + ]); + } + + // Max 2MB, matching the previous multipart contract. + if (filesize($filePath) > 2 * 1024 * 1024) { + $cache->delete($validated['upload_key']); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__('profile.picture_error')], + ]); + } + + // Validate it's really an image FixometerFile knows how to handle (jpeg/png/gif - + // matches the whitelist in FixometerFile::filename()), before we touch any DB state. + $mime = @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $filePath); + + if (! in_array($mime, ['image/jpeg', 'image/png', 'image/gif'], true)) { + $cache->delete($validated['upload_key']); + @unlink($filePath); + + throw ValidationException::withMessages([ + 'upload_key' => [__('profile.picture_error')], + ]); + } + + $file = new \FixometerFile(); + $filename = $file->uploadLocalFile($filePath, 'image', $user->id, env('TBL_USERS'), true); + + // Whether it succeeded or not, the tus temp file has served its purpose - remove it + // and its cache entry so it isn't left behind (and can't be replayed against /photo). + $cache->delete($validated['upload_key']); + @unlink($filePath); + + if (! $filename) { + throw ValidationException::withMessages([ + 'upload_key' => [__('profile.picture_error')], + ]); + } + + return response()->json([ + 'data' => [ + 'path' => $filename, + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/users/me", + * operationId="deleteMyAccountv2", + * tags={"Users"}, + * summary="Soft-delete (and anonymise) the authenticated user's account", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="success", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, description="Data consent required") + * ) + */ + public function deleteMyAccountv2(Request $request): JsonResponse + { + // This endpoint always operates on Auth::user() - there is no id parameter, so a + // user can only ever delete their own account via this route. + $user = Auth::user(); + + $this->authorize('delete', $user); + + $user->delete(); // Will be anonymised automatically by event handlers (see postSoftDeleteUser). + + return response()->json([ + 'data' => [ + 'success' => true, + ], + ]); + } + + /** + * @OA\Delete( + * path="/api/v2/users/{id}", + * operationId="deleteUserv2", + * tags={"Users"}, + * summary="Administrator (or self): soft-delete a user's account", + * description="Id-scoped mirror of DELETE /users/me. Authorised via UserPolicy::delete (self-or-Administrator).", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="success", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ + public function deleteUserv2(int $id): JsonResponse + { + $target = User::findOrFail($id); + + $this->authorize('delete', $target); + + $target->delete(); // Will be anonymised automatically by event handlers (see postSoftDeleteUser). + + return response()->json([ + 'data' => [ + 'success' => true, + ], + ]); + } + + /** + * @OA\Post( + * path="/api/v2/users", + * operationId="createUserv2", + * tags={"Users"}, + * summary="Administrator-only: create a new user account", + * description="Port of the legacy 'Create new user' admin form (UserController::create / includes/modals/create-user.blade.php), adapted so the admin sets the initial password directly rather than a randomly-generated one that was never emailed to anyone. No consent is recorded and no admin-new-user notification is sent - both are specific to self-registration (AuthController::registerv2).", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name", "email", "role", "password"}, + * @OA\Property(property="name", type="string"), + * @OA\Property(property="email", type="string"), + * @OA\Property(property="role", type="integer", description="A roles.idroles value"), + * @OA\Property(property="password", type="string") + * ) + * ), + * @OA\Response( + * response=201, + * description="Created", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/UserAdmin")) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden"), + * @OA\Response(response=422, ref="#/components/responses/ValidationError") + * ) + */ + public function createUserv2(Request $request): JsonResponse + { + // SECURITY: Administrator only, matching develop's + // UserController::create ("Administrators can add users", + // UserController.php:621). The route carries auth:sanctum,api and + // nothing more, so without this ANY authenticated user could create an + // account - and the payload takes a `role`, so any authenticated user + // could mint themselves an Administrator. + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $this->authorize('create', User::class); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users,email', + // min:6 matches AuthController::registerv2's password rule - this is the same + // account, just created by an Administrator instead of via self-registration. + 'password' => 'required|string|min:6', + 'role' => 'required|integer|exists:roles,idroles', + ]); + + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => Hash::make($validated['password']), + 'recovery' => Fixometer::generateHash(), + 'recovery_expires' => date('Y-m-d H:i:s', time() + (24 * 60 * 60)), + 'calendar_hash' => Str::random(15), + // username is NOT NULL with no default - filled below via generateAndSetUsername(), + // exactly like AuthController::registerv2. + 'username' => '', + ]); + + // role excluded from $fillable (security: privilege escalation via mass assignment) + // - must be set via direct assignment, matching registerv2/updateAdminSettingsv2. + $user->role = $validated['role']; + $user->generateAndSetUsername(); + $user->save(); + + return response()->json([ + 'data' => (new UserAdmin($user))->toArray($request), + ], 201); + } + + /** + * @OA\Get( + * path="/api/v2/users", + * operationId="listUsersv2", + * tags={"Users"}, + * summary="List users with optional filtering and sorting", + * description="Administrator only. Paginated.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="email", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="location", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="country", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="role", in="query", required=false, @OA\Schema(type="integer")), + * @OA\Parameter(name="permissions[]", in="query", required=false, description="Only return users whose role holds ALL of these permission ids", @OA\Schema(type="array", @OA\Items(type="integer"))), + * @OA\Parameter(name="sort", in="query", required=false, @OA\Schema(type="string", enum={"name","email","role","location","country","created_at","updated_at"})), + * @OA\Parameter(name="sortdir", in="query", required=false, @OA\Schema(type="string", enum={"asc","desc"})), + * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UserAdmin")), + * @OA\Property(property="meta", type="object") + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) + */ + public function listUsersv2(Request $request): JsonResponse + { + if ($resp = $this->requireAdministrator()) { + return $resp; + } + + $query = User::query() + ->leftJoin('roles', 'roles.idroles', '=', 'users.role') + ->select('users.*', 'roles.role as role_name') + ->withCount('groups'); + + if ($name = $request->input('name')) { + $query->where('users.name', 'like', '%' . $name . '%'); + } + if ($email = $request->input('email')) { + $query->where('users.email', 'like', '%' . $email . '%'); + } + if ($location = $request->input('location')) { + $query->where('users.location', 'like', '%' . $location . '%'); + } + if ($country = $request->input('country')) { + $query->where('users.country_code', '=', $country); + } + if (($role = $request->input('role')) !== null && $role !== '') { + $query->where('users.role', '=', (int) $role); + } + + $validated = $request->validate([ + 'permissions' => 'sometimes|array', + 'permissions.*' => 'integer', + ]); + if (! empty($validated['permissions'])) { + // Permissions are held by roles (roles_permissions), not directly by users - + // mirrors the legacy UserController::search, which filtered on + // array_column($User->getRolePermissions($user->role), 'idpermissions'). + // Restrict to roles that hold EVERY selected permission. + $permissionIds = $validated['permissions']; + $query->whereIn('users.role', function ($subquery) use ($permissionIds) { + $subquery->select('role') + ->from('roles_permissions') + ->whereIn('permission', $permissionIds) + ->groupBy('role') + ->havingRaw('COUNT(DISTINCT permission) = ?', [count($permissionIds)]); + }); + } + + $sortMap = [ + 'name' => 'users.name', + 'email' => 'users.email', + 'role' => 'users.role', + 'location' => 'users.location', + 'country' => 'users.country_code', + 'created_at' => 'users.created_at', + 'updated_at' => 'users.updated_at', + ]; + $sort = $request->input('sort'); + if ($sort && isset($sortMap[$sort])) { + $dir = strtolower($request->input('sortdir', 'asc')); + if (!in_array($dir, ['asc', 'desc'], true)) { + $dir = 'asc'; + } + $query->orderBy($sortMap[$sort], $dir); + } else { + $query->orderBy('users.id', 'asc'); + } + + $perPage = (int) (env('PAGINATE') ?: 30); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => UserAdmin::collection($paginator->getCollection())->toArray($request), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'from' => $paginator->firstItem(), + 'to' => $paginator->lastItem(), + ], + ]); + } + + /** + * @OA\Get( + * path="/api/v2/users/{id}", + * operationId="getPublicProfilev2", + * tags={"Users"}, + * summary="Get a user's public (PII-safe) profile", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="avatar_url", type="string", nullable=true), + * @OA\Property(property="role_name", type="string", nullable=true), + * @OA\Property(property="location", type="string", nullable=true), + * @OA\Property(property="groups", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="skills", type="array", @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * )), + * @OA\Property(property="biography", type="string", nullable=true), + * @OA\Property(property="talk_profile_url", type="string", nullable=true), + * @OA\Property(property="on_talk", type="boolean") + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + * + * PII-safe public profile behind pages/profile/[id].vue and /profile + * (resources/views/user/profile-new.blade.php via the legacy + * UserController::index($id) is the functional spec). No permission check + * beyond the route's own auth middleware: any logged-in user may view any + * profile, exactly as the legacy Blade page did. Fields limited to what + * that page rendered publicly - name, avatar, role name, location, the + * user's groups and skills, and their biography. + */ + public function getPublicProfilev2($id): JsonResponse + { + $user = User::find($id); + + if (! $user) { + return response()->json(['message' => 'User not found'], 404); + } + + // Full size, NOT the thumbnail: profile-new.blade.php renders + // `/uploads/{path}` in a col-3 header block. The navbar avatar is a + // separate, genuinely thumbnail-sized use (SessionController:: + // userPayload), so the two should not share a derivation. + $profile = User::getProfile($user->id); + $avatarUrl = ($profile && $profile->path) ? url('/uploads/'.$profile->path) : null; + + // Mirrors the legacy profile-new.blade.php's + // $user->existsOnDiscourse() / getTalkProfileUrl() pairing: null/false + // when Discourse integration is off or the user has no Discourse + // account, so the client only ever renders a link that actually works. + $onTalk = $user->existsOnDiscourse(); + + return response()->json([ + 'data' => [ + 'id' => (int) $user->id, + 'name' => $user->name, + 'avatar_url' => $avatarUrl, + 'role_name' => optional($user->role()->first())->role, + 'location' => $user->location, + 'groups' => $user->groups->map(fn ($group) => [ + 'id' => (int) $group->idgroups, + 'name' => $group->name, + ])->values(), + 'skills' => $user->skills->map(fn ($skill) => [ + 'id' => (int) $skill->id, + 'name' => $skill->skill_name, + ])->values(), + 'biography' => $user->biography, + 'talk_profile_url' => $onTalk ? $user->getTalkProfileUrl() : null, + 'on_talk' => $onTalk, + ], + ]); + } } diff --git a/app/Http/Controllers/API/UserGroupsController.php b/app/Http/Controllers/API/UserGroupsController.php index 31806594f6..8e186ab222 100644 --- a/app/Http/Controllers/API/UserGroupsController.php +++ b/app/Http/Controllers/API/UserGroupsController.php @@ -19,6 +19,41 @@ class UserGroupsController extends Controller * Only confirmed group memberships - pending invitations not pulled in. * * Only Administrators allowed to access this endpoint. + * + * @OA\Get( + * path="/api/usersgroups/changes", + * operationId="getUserGroupChanges", + * tags={"UserGroups"}, + * summary="List confirmed group-membership changes", + * description="Administrator only. Used by Zapier as a trigger. Built from the audit log for App\UserGroups, restricted to confirmed (status=1) memberships whose user and group both still opt in to Zapier pushes (User/Group::changesShouldPushToZapier()). Pending invitations are not included.", + * security={{"apiToken":{}}}, + * @OA\Parameter( + * name="date_from", + * description="Only include audit events created on or after this date/time. Omit for all history.", + * required=false, + * in="query", + * @OA\Schema(type="string", format="date-time") + * ), + * @OA\Response( + * response=200, + * description="Membership changes, most recently audited first", + * @OA\JsonContent(type="array", @OA\Items( + * @OA\Property(property="idusers_groups", type="integer", description="Primary key of the users_groups row"), + * @OA\Property(property="id", type="string", description="md5 hash of idusers_groups + change_occurred_at; unique per change record"), + * @OA\Property(property="change_type", type="string", description="Audit event type, e.g. created/updated/deleted", example="updated"), + * @OA\Property(property="change_occurred_at", type="string", format="date-time"), + * @OA\Property(property="user_id", type="integer"), + * @OA\Property(property="user_email", type="string"), + * @OA\Property(property="role", type="string", description="Role name for the membership, or 'Unknown' if the role record no longer exists"), + * @OA\Property(property="group_id", type="integer"), + * @OA\Property(property="group_name", type="string"), + * @OA\Property(property="group_area", type="string"), + * @OA\Property(property="group_country", type="string") + * )) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) */ public static function changes(Request $request) { @@ -31,13 +66,27 @@ public static function changes(Request $request) $userGroupAudits = self::getUserGroupAudits($dateFrom); + // Batched, not per-audit. This ran UserGroups::find() and Group::find() + // inside the loop, so a caller asking for all changes (no dateFrom) paid + // two queries per audit row - thousands of round trips on a mature + // database. `volunteer` is eager-loaded for the same reason. + $associations = UserGroups::withTrashed() + ->with('volunteer') + ->whereIn('idusers_groups', $userGroupAudits->pluck('auditable_id')->unique()->all()) + ->get() + ->keyBy('idusers_groups'); + + $groups = Group::whereIn('idgroups', $associations->pluck('group')->unique()->all()) + ->get() + ->keyBy('idgroups'); + $userGroupChanges = []; foreach ($userGroupAudits as $audit) { - $userGroupAssociation = UserGroups::withTrashed()->find($audit->auditable_id); + $userGroupAssociation = $associations->get($audit->auditable_id); if (! is_null($userGroupAssociation) && $userGroupAssociation->isConfirmed()) { $user = $userGroupAssociation->volunteer; - $group = Group::find($userGroupAssociation->group); - if ($user->changesShouldPushToZapier() && $group->changesShouldPushToZapier()) { + $group = $groups->get($userGroupAssociation->group); + if ($user && $group && $user->changesShouldPushToZapier() && $group->changesShouldPushToZapier()) { $userGroupChanges[] = self::mapDetailsAndAuditToChange($userGroupAssociation, $audit); } } @@ -92,6 +141,26 @@ protected static function mapDetailsAndAuditToChange($userGroupAssociation, $aud /** * Leave the specified group. * + * @OA\Delete( + * path="/api/usersgroups/{id}", + * operationId="leaveGroupLegacy", + * tags={"UserGroups"}, + * summary="Leave a group (legacy endpoint)", + * description="Legacy - despite the routes/api.php comment, the Nuxt client actually uses DELETE /api/v2/groups/{id}/members/me (GroupMembershipController::leavev2). Kept for backwards compatibility. Idempotent: if the authenticated user has no confirmed membership of the group (e.g. already left), this still returns a 200 success response rather than an error.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", description="Group id", required=true, in="path", @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Left (or was already not a member)", + * @OA\JsonContent( + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="all_restarters_count", type="integer", description="Group's restarter-count column after the change"), + * @OA\Property(property="all_hosts_count", type="integer", description="Group's host-count column after the change") + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + * * @return \Illuminate\Http\Response */ public function leave(Request $request, int $id) diff --git a/app/Http/Controllers/ApiController.php b/app/Http/Controllers/ApiController.php index 7e6f2e05da..78aa75005f 100644 --- a/app/Http/Controllers/ApiController.php +++ b/app/Http/Controllers/ApiController.php @@ -15,7 +15,7 @@ * @OA\Info( * version="2.0.0", * title="Restarters API", - * description="An API for accessing Restarters data. No API authorisation is necessary - all data is read-only and public.", + * description="The Restarters API. The v2 surface (`/api/v2/*`) powers the Nuxt single-page app and is authenticated with a Sanctum bearer token (`Authorization: Bearer `) unless an operation is explicitly marked public. A small number of legacy read-only endpoints outside `/api/v2` use the `?api_token=` query-string convention (see the ApiKeyAuth scheme).", * @OA\Contact( * email="tech@therestartproject.org" * ), @@ -36,16 +36,55 @@ * ) * * @OA\SecurityScheme( + * securityScheme="apiToken", + * type="http", + * scheme="bearer", + * bearerFormat="Sanctum", + * description="Sanctum personal-access token issued by POST /api/v2/auth/login or /register. Send as `Authorization: Bearer `. This is the scheme every authenticated /api/v2 operation requires.", + * ) + * + * @OA\SecurityScheme( * securityScheme="ApiKeyAuth", * type="apiKey", * in="query", * name="api_token", + * description="Legacy query-string token (`?api_token=`) used only by the pre-v2 read-only endpoints outside /api/v2.", * ) */ class ApiController extends Controller { /** - * Embedded at https://therestartproject.org + * @OA\Get( + * path="/api/homepage_data", + * operationId="getHomepageDataLegacy", + * tags={"Legacy"}, + * summary="Get sitewide headline stats for the public homepage widget", + * description="Legacy, unauthenticated endpoint. Used from DeviceController and embedded at https://therestartproject.org. Aggregates events-held/participants/hours-volunteered/items-fixed/waste/CO2 figures across all past, non-deleted events. The result is cached for 12 hours (`homepage_data` cache key); a stale or empty result may be served briefly while another worker rebuilds the cache.", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="events_held", type="integer", example=1200), + * @OA\Property(property="participants", type="integer", example=45000), + * @OA\Property(property="hours_volunteered", type="integer", example=180000), + * @OA\Property(property="items_fixed", type="integer", example=28000), + * @OA\Property(property="waste_powered", type="number", example=12000), + * @OA\Property(property="waste_unpowered", type="number", example=8000), + * @OA\Property(property="waste_total", type="number", example=20000), + * @OA\Property(property="co2_powered", type="number", example=5000), + * @OA\Property(property="co2_unpowered", type="number", example=3000), + * @OA\Property(property="co2_total", type="number", example=8000), + * @OA\Property(property="fixed_powered", type="integer", example=18000), + * @OA\Property(property="fixed_unpowered", type="integer", example=10000), + * @OA\Property(property="total_powered", type="integer", example=25000), + * @OA\Property(property="total_unpowered", type="integer", example=15000), + * @OA\Property(property="weights", type="number", description="Alias of waste_total, kept for backward compatibility.", example=20000), + * @OA\Property(property="ewaste", type="number", description="Alias of waste_powered, kept for backward compatibility.", example=12000), + * @OA\Property(property="unpowered_waste", type="number", description="Alias of waste_unpowered, kept for backward compatibility.", example=8000), + * @OA\Property(property="emissions", type="number", description="Alias of co2_total, kept for backward compatibility.", example=8000) + * ) + * ) + * ) */ public static function homepage_data(): JsonResponse { @@ -65,6 +104,7 @@ public static function homepage_data(): JsonResponse ->whereNull('deleted_at') ->where('event_end_utc', '<', now()) ->selectRaw(" + COUNT(*) as events_held, SUM(pax) as participants, SUM(CASE WHEN cancelled = 1 THEN 3 @@ -74,6 +114,10 @@ public static function homepage_data(): JsonResponse ") ->first(); + // events_held mirrors legacy Fixometer::computeStats()'s + // partiesCount (past, non-deleted events) - it is the "Events + // held" figure in the logged-out header stats bar. + $result['events_held'] = (int) ($eventStats->events_held ?? 0); $result['participants'] = (int) ($eventStats->participants ?? 0); $result['hours_volunteered'] = (int) ($eventStats->hours_volunteered ?? 0); @@ -113,6 +157,41 @@ public static function homepage_data(): JsonResponse ->json($result, 200); } + /** + * @OA\Get( + * path="/api/party/{id}/stats", + * operationId="getPartyStatsLegacy", + * tags={"Legacy","Events"}, + * summary="Get impact stats for a single event", + * description="Legacy, unauthenticated endpoint used from TRP.org.", + * @OA\Parameter( + * name="id", + * description="Event (party) id", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="num_participants", type="integer", example=25), + * @OA\Property(property="num_volunteers", type="integer", example=6), + * @OA\Property(property="num_hours_volunteered", type="integer", example=42), + * @OA\Property(property="num_fixed_devices", type="integer", example=14), + * @OA\Property(property="num_repairable_devices", type="integer", example=3), + * @OA\Property(property="num_dead_devices", type="integer", example=2), + * @OA\Property(property="kg_powered_co2_diverted", type="integer", example=120), + * @OA\Property(property="kg_unpowered_co2_diverted", type="integer", example=40), + * @OA\Property(property="kg_powered_waste_diverted", type="integer", example=300), + * @OA\Property(property="kg_unpowered_waste_diverted", type="integer", example=90), + * @OA\Property(property="kg_co2_diverted", type="integer", example=160), + * @OA\Property(property="kg_waste_diverted", type="integer", example=390) + * ) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ public static function partyStats($partyId): JsonResponse { $event = Party::where('idevents', $partyId)->first(); @@ -143,6 +222,41 @@ public static function partyStats($partyId): JsonResponse return response()->json($result, 200); } + /** + * @OA\Get( + * path="/api/group/{id}/stats", + * operationId="getGroupStatsLegacy", + * tags={"Legacy","Groups"}, + * summary="Get impact stats for a single group", + * description="Legacy, unauthenticated endpoint used from TRP.org.", + * @OA\Parameter( + * name="id", + * description="Group id", + * required=true, + * in="path", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="num_parties", type="integer", example=18), + * @OA\Property(property="num_participants", type="integer", example=450), + * @OA\Property(property="num_hours_volunteered", type="integer", example=760), + * @OA\Property(property="num_fixed_devices", type="integer", example=260), + * @OA\Property(property="num_repairable_devices", type="integer", example=40), + * @OA\Property(property="num_dead_devices", type="integer", example=20), + * @OA\Property(property="kg_powered_co2_diverted", type="integer", example=2100), + * @OA\Property(property="kg_unpowered_co2_diverted", type="integer", example=700), + * @OA\Property(property="kg_powered_waste_diverted", type="integer", example=5400), + * @OA\Property(property="kg_unpowered_waste_diverted", type="integer", example=1600), + * @OA\Property(property="kg_co2_diverted", type="integer", example=2800), + * @OA\Property(property="kg_waste_diverted", type="integer", example=7000) + * ) + * ), + * @OA\Response(response=404, ref="#/components/responses/NotFound") + * ) + */ public static function groupStats($groupId): JsonResponse { $group = Group::where('idgroups', $groupId)->first(); @@ -174,6 +288,33 @@ public static function groupStats($groupId): JsonResponse return response()->json($result, 200); } + /** + * @OA\Get( + * path="/api/users/me", + * operationId="getUserInfoLegacy", + * tags={"Legacy","Users"}, + * summary="Get the authenticated user's profile", + * description="Legacy endpoint kept for backward compatibility; the Nuxt client uses GET /api/v2/session instead. Returns the raw user row (all columns) with credential/PII fields (api_token, calendar_hash, recovery, recovery_expires, mediawiki, latitude, longitude) stripped.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="id", type="integer", example=42), + * @OA\Property(property="name", type="string", example="Jane Doe"), + * @OA\Property(property="email", type="string", example="jane@example.com"), + * @OA\Property(property="username", type="string", nullable=true, example="janedoe"), + * @OA\Property(property="role", type="integer", example=3), + * @OA\Property(property="language", type="string", nullable=true, example="en"), + * @OA\Property(property="location", type="string", nullable=true, example="London"), + * @OA\Property(property="country_code", type="string", nullable=true, example="GB"), + * @OA\Property(property="created_at", type="string", format="date-time", nullable=true), + * @OA\Property(property="updated_at", type="string", format="date-time", nullable=true) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated") + * ) + */ public static function getUserInfo(): JsonResponse { $user = Auth::user(); @@ -185,6 +326,32 @@ public static function getUserInfo(): JsonResponse return response()->json($user->toArray()); } + /** + * @OA\Get( + * path="/api/users", + * operationId="getUserListLegacy", + * tags={"Legacy","Users"}, + * summary="List all users (Administrator only)", + * description="Legacy endpoint. Returns every non-deleted user as a raw user row (all columns except the model's hidden credential fields), newest-created first.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer", example=42), + * @OA\Property(property="name", type="string", example="Jane Doe"), + * @OA\Property(property="email", type="string", example="jane@example.com"), + * @OA\Property(property="role", type="integer", example=3), + * @OA\Property(property="created_at", type="string", format="date-time", nullable=true) + * ) + * ) + * ), + * @OA\Response(response=401, ref="#/components/responses/Unauthenticated"), + * @OA\Response(response=403, ref="#/components/responses/Forbidden") + * ) + */ public static function getUserList() { $authenticatedUser = Auth::user(); @@ -200,7 +367,36 @@ public static function getUserList() } /** - * List/search devices. + * @OA\Get( + * path="/api/devices/{page}/{size}", + * operationId="getDevicesLegacy", + * tags={"Legacy","Devices"}, + * summary="Search/paginate devices", + * description="Legacy, unauthenticated endpoint used by the Vue client. Joins events/groups/categories to support filtering; results are ordered by sortBy/sortDesc.", + * @OA\Parameter(name="page", description="1-based page number", required=true, in="path", @OA\Schema(type="integer", example=1)), + * @OA\Parameter(name="size", description="Number of items per page", required=true, in="path", @OA\Schema(type="integer", example=20)), + * @OA\Parameter(name="powered", description="Filter by whether the device's category is powered", required=false, in="query", @OA\Schema(type="string", enum={"true","false"})), + * @OA\Parameter(name="sortBy", description="Column to sort by", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="sortDesc", description="Sort direction, passed straight through to orderBy()", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="category", description="Filter by category id", required=false, in="query", @OA\Schema(type="integer")), + * @OA\Parameter(name="brand", description="Filter by brand (partial match)", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="model", description="Filter by model (partial match)", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="item_type", description="Filter by item type (partial match)", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="status", description="Filter by repair status", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="comments", description="Filter by problem/comments text (partial match)", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="wiki", description="Only include devices flagged for the wiki", required=false, in="query", @OA\Schema(type="boolean")), + * @OA\Parameter(name="group", description="Filter by group name (partial match)", required=false, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="from_date", description="Only include devices from events starting on/after this date", required=false, in="query", @OA\Schema(type="string", format="date")), + * @OA\Parameter(name="to_date", description="Only include devices from events ending on/before this date", required=false, in="query", @OA\Schema(type="string", format="date")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="count", type="integer", description="Total number of matching devices across all pages", example=532), + * @OA\Property(property="items", type="array", @OA\Items(ref="#/components/schemas/Device")) + * ) + * ) + * ) */ public static function getDevices(Request $request, $page, $size): JsonResponse { @@ -296,6 +492,25 @@ public static function getDevices(Request $request, $page, $size): JsonResponse ]); } + /** + * @OA\Get( + * path="/api/timezones", + * operationId="getTimezonesLegacy", + * tags={"Legacy"}, + * summary="List all known IANA timezone identifiers", + * description="Legacy, unauthenticated endpoint backed by PHP's DateTimeZone::listIdentifiers(ALL_WITH_BC).", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * type="array", + * @OA\Items( + * @OA\Property(property="name", type="string", example="Europe/London") + * ) + * ) + * ) + * ) + */ public function timezones(): JsonResponse { $zones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC); $ret = []; diff --git a/app/Http/Controllers/Auth/BridgeController.php b/app/Http/Controllers/Auth/BridgeController.php new file mode 100644 index 0000000000..71538bc8c3 --- /dev/null +++ b/app/Http/Controllers/Auth/BridgeController.php @@ -0,0 +1,87 @@ +safeRedirect($request->query('redirect')); + + // Target the web guard explicitly: the session is what we are here to + // establish, regardless of what the default guard has been set to. + if (Auth::guard('web')->check()) { + // Already have a web session (e.g. second bridge hop); no ticket needed. + return redirect($redirect); + } + + $user = SsoTicket::consume($request->query('ticket')); + + if (! $user) { + // Invalid/expired ticket: bounce to the SPA login, preserving the + // intended destination so the user can retry after logging in. + return redirect(config('restarters.frontend_url').'/login?redirect='.urlencode($redirect)); + } + + // Fires Illuminate\Auth\Events\Login → LogSuccessfulLogin + LogInToWiki. + Auth::guard('web')->login($user); + $request->session()->regenerate(); + + return redirect($redirect); + } + + /** + * Only ever redirect to: this host's Discourse SSO entrypoint, the wiki, + * Discourse itself, or the SPA. Anything else falls back to the SPA — + * this is what stops /auth/bridge being an open redirect. + */ + private function safeRedirect(?string $target): string + { + $frontend = rtrim(config('restarters.frontend_url'), '/'); + + if (! $target) { + return $frontend; + } + + $allowedPrefixes = array_filter([ + url('/discourse/sso'), + '/discourse/sso', + config('restarters.wiki.base_url'), + config('services.discourse.url'), + $frontend, + ]); + + foreach ($allowedPrefixes as $prefix) { + $prefix = rtrim($prefix, '/'); + + // Match on an origin/path *boundary*, not a bare string prefix: a + // plain str_starts_with lets "https://app.example.com.attacker.com" + // pass the "https://app.example.com" allowlist entry (open redirect). + // Only the exact target, or one continuing with '/' or '?', is safe. + if ($target === $prefix + || str_starts_with($target, $prefix.'/') + || str_starts_with($target, $prefix.'?')) { + return $target; + } + } + + return $frontend; + } +} diff --git a/app/Http/Controllers/Auth/ConfirmPasswordController.php b/app/Http/Controllers/Auth/ConfirmPasswordController.php deleted file mode 100644 index 138c1f08a2..0000000000 --- a/app/Http/Controllers/Auth/ConfirmPasswordController.php +++ /dev/null @@ -1,40 +0,0 @@ -middleware('auth'); - } -} diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php deleted file mode 100644 index 465c39ccf9..0000000000 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ /dev/null @@ -1,22 +0,0 @@ -middleware('guest')->except(['index', 'logout']); - } - - /** - * Override login from AuthenticateUsers - * - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse - * - * @throws \Illuminate\Validation\ValidationException - */ - public function login(Request $request) - { - $this->validateLogin($request); - - // If the class is using the ThrottlesLogins trait, we can automatically throttle - // the login attempts for this application. We'll key this by the username and - // the IP address of the client making these requests into this application. - if ($this->hasTooManyLoginAttempts($request)) { - $this->fireLockoutEvent($request); - - return $this->sendLockoutResponse($request); - } - - if ($this->attemptLogin($request)) { - \Cookie::queue(\Cookie::make('authenticated', $request->email, config('session.lifetime'), null, config('session.domain'))); - - try { - return $this->sendLoginResponse($request); - } catch (\Throwable $e) { - Log::error('Login post-auth error for user ' . $request->email . ': ' . $e->getMessage(), [ - 'exception' => $e, - 'trace' => $e->getTraceAsString(), - ]); - throw $e; - } - } - - // If the login attempt was unsuccessful we will increment the number of attempts - // to login and redirect the user back to the login form. Of course, when this - // user surpasses their maximum number of attempts they will get locked out. - $this->incrementLoginAttempts($request); - - return $this->sendFailedLoginResponse($request); - } - - /** - * Override validateLogin from AuthenticateUsers - */ - protected function validateLogin(Request $request): void - { - if (env('HONEYPOT_DISABLE', false)) { - // This is used in Playwright testing where we get many requests in a short time. - // TODO There is probably a better place to put this code. - app('honeypot')->disable(); - } - - $this->validate($request, [ - $this->username() => 'required|email', - 'password' => 'required|string', - 'my_name' => 'honeypot', - 'my_time' => 'required|honeytime:0', - ]); - } - - /** - * Override showLoginForm from AuthenticateUsers - */ - public function showLoginForm(): View - { - $stats = Fixometer::loginRegisterStats(); - - return view('auth.login', [ - 'co2Total' => $stats['co2Total'], - 'wasteTotal' => $stats['wasteTotal'], - 'partiesCount' => $stats['partiesCount'], - 'deviceCount' => $stats['deviceCount'], - ]); - } -} diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php deleted file mode 100644 index 4072136096..0000000000 --- a/app/Http/Controllers/Auth/RegisterController.php +++ /dev/null @@ -1,78 +0,0 @@ -middleware('guest'); - } - - /** - * Get a validator for an incoming registration request. - */ - protected function validator(array $data): \Illuminate\Contracts\Validation\Validator - { - return Validator::make($data, [ - 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:6|confirmed', - 'my_name' => 'honeypot', - 'my_time' => 'required|honeytime:5', - ]); - } - - /** - * Create a new user instance after a valid registration. - */ - protected function create(array $data): User - { - $user = User::create([ - 'name' => $data['name'], - 'email' => $data['email'], - 'password' => Hash::make($data['password']), - 'recovery' => substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24), - 'recovery_expires' => strftime('%Y-%m-%d %X', time() + (24 * 60 * 60)), - ]); - - // role excluded from $fillable (security: C2/M1); set via direct assignment - $user->role = 4; - $user->save(); - - Session::createSession($user->id); - - return $user; - } -} diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php deleted file mode 100644 index e70d96ae6c..0000000000 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ /dev/null @@ -1,39 +0,0 @@ -middleware('guest'); - } -} diff --git a/app/Http/Controllers/BrandsController.php b/app/Http/Controllers/BrandsController.php deleted file mode 100644 index ebbf752ec1..0000000000 --- a/app/Http/Controllers/BrandsController.php +++ /dev/null @@ -1,78 +0,0 @@ -get(); - - return view('brands.index', [ - 'title' => 'Brands', - 'brands' => $all_brands, - ]); - } - - public function postCreateBrand(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $brand = Brands::create([ - 'brand_name' => $request->input('brand_name'), - ]); - - return Redirect::to('brands/edit/'.$brand->id)->with('success', __('brands.create_success')); - } - - public function getEditBrand($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $brand = Brands::find($id); - - return view('brands.edit', [ - 'title' => 'Edit Brand', - 'brand' => $brand, - ]); - } - - public function postEditBrand($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - Brands::find($id)->update([ - 'brand_name' => $request->input('brand-name'), - ]); - - return Redirect::back()->with('success', __('brands.update_success')); - } - - public function getDeleteBrand($id): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - Brands::find($id)->delete(); - - return Redirect::back()->with('message', __('brands.delete_success')); - } -} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php deleted file mode 100644 index c853476dec..0000000000 --- a/app/Http/Controllers/CategoryController.php +++ /dev/null @@ -1,108 +0,0 @@ -findAll(); - $clusters = $Category->listed(); - - // Prepare data for Vue table - $tableData = []; - foreach ($list as $category) { - // Find cluster name - $clusterName = null; - if (!empty($category->cluster)) { - foreach ($clusters as $cluster) { - if ($cluster->idclusters == $category->cluster) { - $clusterName = $cluster->name; - break; - } - } - } - - // Prepare reliability badge HTML - $reliability = $category->footprint_reliability ?? 6; - $colors = [ - 1 => '#AD2C1C', - 2 => '#FF1B00', - 3 => '#FFBA00', - 4 => '#43B136', - 5 => '#26781C', - 6 => '#FFBA00', - ]; - $color = $colors[$reliability] ?? '#FFBA00'; - $reliabilityHtml = '' . __('admin.reliability-' . $reliability) . ''; - - $tableData[] = [ - 'idcategories' => $category->idcategories, - 'name' => $category->name, - 'cluster' => $clusterName, - 'cluster_name' => $clusterName, - 'weight' => $category->weight, - 'footprint' => $category->footprint, - 'footprint_html' => $category->footprint, - 'reliability' => $reliabilityHtml, - ]; - } - - return view('category.index', [ - 'list' => $list, - 'categories' => $clusters, - 'tableData' => $tableData, - ]); - } - - public function getEditCategory($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $category = Category::find($id); - - $c = new Category; - $categories = $c->listed(); - - return view('category.edit', [ - 'title' => 'Edit Category', - 'category' => $category, - 'categories' => $categories, - ]); - } - - public function postEditCategory($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - try { - $category = Category::find($id); - $category->update([ - 'name' => $request->input('category_name'), - 'weight' => $request->input('weight'), - 'footprint' => $request->input('co2_footprint'), - 'footprint_reliability' => $request->input('reliability'), - 'cluster' => $request->input('category_cluster'), - 'description_short' => $request->input('categories_desc') - ]); - } catch (\Exception $e) { - return redirect()->back()->with('danger', __('category.update_error')); - } - - return redirect()->back()->with('success', __('category.update_success')); - } -} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 4c97f6f73e..5b9a967f1b 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,12 +2,71 @@ namespace App\Http\Controllers; +use App\Helpers\Fixometer; +use Auth; +use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller as BaseController; use Party; class Controller extends BaseController { use AuthorizesRequests, ValidatesRequests; + + /** + * Guard an Administrator-only action. Returns a 403 JSON response when the + * current user is not an Administrator, or null when they are - so callers + * do `if ($resp = $this->requireAdministrator()) { return $resp; }`. + * + * Extracted from ~17 byte-identical inline checks across the admin CRUD + * controllers (2026-07 API audit - a copy that gets missed silently + * exposes a mutation). + */ + protected function requireAdministrator(): ?JsonResponse + { + // getUser(), not Auth::user(): the latter is null when the caller + // authenticates with ?api_token= (the legacy api guard), so an + // Administrator using a token was told Forbidden. getUser() is this + // class's own resolver for exactly that - session, then sanctum, then + // the api guard. + if (!Fixometer::hasRole($this->getUser(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return null; + } + + /** + * Resolve the authenticated user, accepting a session login, a Sanctum + * bearer token (the Nuxt SPA), or the legacy api guard. Throws + * AuthenticationException (401) if none authenticate. + * + * Hoisted here from four byte-identical private copies in the API + * controllers (Device/Event/Group/Alert) - see the 2026-07 API audit. + */ + protected function getUser() + { + // We want to allow this call to work if a) we are logged in as a user, or b) we have a valid API token. + // + // This is a slightly odd thing to do, but it is necessary to get both the PHPUnit tests and the + // real client use of the API to work. + $user = Auth::user(); + + if (!$user) { + // SPA bearer tokens authenticate via the sanctum guard. + $user = auth('sanctum')->user(); + } + + if (!$user) { + $user = auth('api')->user(); + } + + if (!$user) { + throw new AuthenticationException(); + } + + return $user; + } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php deleted file mode 100644 index 34286640f0..0000000000 --- a/app/Http/Controllers/DashboardController.php +++ /dev/null @@ -1,89 +0,0 @@ -update( - [ - 'language' => session('locale'), - ] - ); - - $new_groups = []; - - if (! is_null($user->latitude) && ! is_null($user->longitude)) { - // Look for new nearby groups that we're not already a member of. Eloquent is just getting in the way - // here so do a raw query. - $new_groups = $user->groupsNearby(3, "1 month ago"); - } - - $expanded_events = []; - - $upcoming_events = Party::futureForUser()->get()->take(5); - - foreach ($upcoming_events as $event) { - $expanded_event = \App\Http\Controllers\PartyController::expandEvent($event, null); - $expanded_event['the_group'] = \App\Group::find($event->group); - $expanded_events[] = $expanded_event; - } - - $upcoming_events = $expanded_events; - - // We want the users own groups. Look for groups where user ID exists in pivot table. We have to explicitly - // test on deleted_at because the normal filtering out of soft deletes won't happen for joins. - $your_groups = Group::join('users_groups', 'users_groups.group', '=', 'groups.idgroups') - ->leftJoin('events', 'events.group', '=', 'groups.idgroups') - ->where('users_groups.user', $user->id) - ->whereNull('users_groups.deleted_at') - ->orderBy('groups.name', 'ASC') - ->groupBy('groups.idgroups', 'groups.name') - ->select(['groups.idgroups', 'groups.name', 'users_groups.role', 'groups.archived_at']) - ->take(5) - ->get(); - - if ($your_groups) { - // We have some groups - return them. - foreach ($your_groups as $group) { - $group_image = $group->groupImage; - if (is_object($group_image) && is_object($group_image->image)) { - $group_image->image->path; - } - } - } - - // Find nearby ones to show if we need to. - $groupsNearYou = $user->groupsNearby(2); - - return view( - 'dashboard.index', - [ - 'user' => $user, - 'groups_near_you' => $groupsNearYou, - 'upcoming_events' => $upcoming_events, - 'your_groups' => $your_groups, - 'seeAllTopicsLink' => env('DISCOURSE_URL').'/latest', - 'new_groups' => $new_groups, - ] - ); - } - - public function getHostDash(): View - { - return view('dashboard.host'); - } -} diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index 4dc9c9ffb5..09c4c78dd2 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -72,7 +72,16 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) } $filename .= '.csv'; - $file = fopen(base_path() . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $filename, 'w+'); + + // Built in a temp file, not public/. The rows below are filtered by + // userCanSeeEvent for THIS caller, so the finished CSV holds whatever + // that caller was allowed to see - writing it under the docroot + // published it at a guessable URL (the name derives from the group or + // event name) and left it there for anyone to fetch, since + // Response::download does not remove the file. $filename stays as the + // download's presented name. + $path = tempnam(sys_get_temp_dir(), 'repair-data'); + $file = fopen($path, 'w+'); $me = auth()->user(); @@ -145,7 +154,7 @@ public function devices(Request $request, $idevents = NULL, $idgroups = NULL) 'Content-Type' => 'text/csv', ]; - return Response::download(base_path() . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $filename, $filename, $headers); + return Response::download($path, $filename, $headers)->deleteFileAfterSend(true); } /** @@ -170,7 +179,25 @@ public function networkEvents(Request $request, $id) return $this->exportEvents($parties); } + /** + * Drop events the caller isn't allowed to see. + * + * undeleted() excludes deleted events, not unapproved ones, so without + * this an anonymous request returned events belonging to groups still + * awaiting moderation - data /api/v2 withholds from the same caller + * (User::userCanSeeEvent, asserted by APIv2EventVisibilityTest). The + * device export has always filtered this way; the event export did not. + */ + private function visibleTo($parties) + { + $me = auth()->user(); + + return $parties->filter(fn ($party) => User::userCanSeeEvent($me, $party)); + } + private function exportEvents($parties) { + $parties = $this->visibleTo($parties); + // We can't put accented characters into a CSV file, so flatten them. // Use //TRANSLIT//IGNORE to handle characters that can't be transliterated on // servers with older glibc (e.g. 2.27) and POSIX locale, which lack transliteration @@ -220,7 +247,12 @@ private function exportEvents($parties) { // write content to file $filename = 'events.csv'; - $file = fopen($filename, 'w+'); + // Per-request temp file. This was a fixed, relative path, so every + // caller wrote to the same events.csv in the process working + // directory - two concurrent exports for different groups raced, and + // one caller could be handed the other's rows. + $path = tempnam(sys_get_temp_dir(), 'events'); + $file = fopen($path, 'w+'); fputcsv($file, $headers); foreach ($PartyArray as $d) { @@ -232,6 +264,6 @@ private function exportEvents($parties) { 'Content-Type' => 'text/csv', ]; - return Response::download($filename, $filename, $headers); + return Response::download($path, $filename, $headers)->deleteFileAfterSend(true); } } diff --git a/app/Http/Controllers/GroupController.php b/app/Http/Controllers/GroupController.php index 7ae9c17d39..2bbd070580 100644 --- a/app/Http/Controllers/GroupController.php +++ b/app/Http/Controllers/GroupController.php @@ -32,11 +32,9 @@ use FixometerFile; use Illuminate\Database\QueryException; use Illuminate\Http\Request; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Notification; use Spatie\ValidationRules\Rules\Delimited; -use Carbon\Carbon; class GroupController extends Controller { @@ -64,7 +62,7 @@ private function indexVariations($tab, $network) // Look for groups we have joined, not just been invited to. We have to explicitly test on deleted_at because // the normal filtering out of soft deletes won't happen for joins. - $your_groups =array_column(Group::with(['networks']) + $your_groups = array_column(Group::with(['networks']) ->join('users_groups', 'users_groups.group', '=', 'groups.idgroups') ->leftJoin('events', 'events.group', '=', 'groups.idgroups') ->where('users_groups.user', $user->id) @@ -75,13 +73,40 @@ private function indexVariations($tab, $network) ->get() ->toArray(), 'idgroups'); - // We pass a high limit to the groups nearby; there is a distance limit which will normally kick in first. - $groups_near_you = array_column($user->groupsNearby(1000), 'idgroups'); + $nearby_groups = []; + $min_lat = 90; + $max_lat = -90; + $min_lng = 180; + $max_lng = -180; + + if ($user->latitude || $user->longitude || $user->country_code) { + // We pass a high limit to the groups nearby; there is a distance limit which will normally kick in first. + $nearby_groups = $user->groupsNearby(1000); + + // Now find the lat/lng bounding box which contains these groups. + foreach ($nearby_groups as $group) { + if ($group->latitude < $min_lat) { + $min_lat = $group->latitude; + } + if ($group->latitude > $max_lat) { + $max_lat = $group->latitude; + } + if ($group->longitude < $min_lng) { + $min_lng = $group->longitude; + } + if ($group->longitude > $max_lng) { + $max_lng = $group->longitude; + } + } + } return view('group.index', [ - 'groups' => GroupController::expandGroups($groups, $your_groups, $groups_near_you), + 'your_groups' => $your_groups, + 'nearby_groups' => [ [ $min_lat, $min_lng ], [ $max_lat, $max_lng ] ], 'your_area' => $user->location, - 'tab' => $tab, + 'your_lat' => $user->latitude, + 'your_lng' => $user->longitude, + 'tab' => (!$tab || $tab === 'mine') ? 'mine' : 'other', 'network' => $network, 'networks' => $networks, 'all_group_tags' => $all_group_tags, @@ -105,7 +130,9 @@ public function nearby() public function network($id) { - return $this->indexVariations('all', $id); + // Retired: network coordinators now see their groups on the network + // page itself (map + list). Kept as a redirect for old links. + return redirect('/networks/' . $id); } public function create(Request $request) @@ -388,13 +415,21 @@ public function postSendInvite(Request $request): RedirectResponse ])); } + /** + * Emailed deep link (App\Http\Controllers\API\GroupMembershipController:236). The status-hash + * DB update is frozen server-side (F2-5) - it's idempotent and this is the only writer - but + * the final destination is now the SPA's group page rather than the Blade one, with a query + * flag instead of session flash (a redirect out of this app can't carry Laravel session state). + */ public function confirmInvite($group_id, $hash): RedirectResponse { + $frontend = rtrim(config('restarters.frontend_url'), '/'); + // Find user/group relationship based on the invitation hash. $user_group = UserGroups::where('status', $hash)->where('group', $group_id)->first(); if (empty($user_group)) { \Sentry\CaptureMessage(__('groups.invite_invalid')); - return redirect('/group/view/'.intval($group_id))->with('warning', __('groups.invite_invalid')); + return redirect($frontend.'/group/view/'.intval($group_id).'?invite=invalid'); } // Set user as confirmed member of group. @@ -417,7 +452,7 @@ public function confirmInvite($group_id, $hash): RedirectResponse ])); } - return redirect('/group/view/'.$user_group->group)->with('success', __('groups.invite_confirmed')); + return redirect($frontend.'/group/view/'.$user_group->group.'?joined=1'); } public function edit(Request $request, $id, Geocoder $geocoder) @@ -481,68 +516,6 @@ public function delete($id): RedirectResponse } } - public static function expandGroups($groups, $your_groupids, $nearby_groupids) - { - $ret = []; - $user = Auth::user(); - - if ($groups) { - foreach ($groups as $group) { - $group_image = $group->groupImage; - - $event = $group->nextUpcomingParty; - - // We want to return the distance from our own location. - $distance = null; - $grouplat = $group->latitude; - $grouplng = $group->longitude; - $userlat = $user->latitude; - $userlng = $user->longitude; - - if ($grouplat !== null && $grouplng !== null && $userlat !== null && $userlng !== null) { - if ($grouplat == $userlat && $grouplng == $userlng) { - $distance = 0; - } else { - $distance = 6371 * acos( cos(deg2rad($userlat)) * cos(deg2rad($grouplat)) * cos(deg2rad($grouplng) - - deg2rad($userlng)) + sin(deg2rad($userlat) ) * sin(deg2rad($grouplat))); - } - } - - $ret[] = [ - 'idgroups' => $group->idgroups, - 'name' => $group->name, - 'image' => (is_object($group_image) && is_object($group_image->image)) ? - asset('uploads/mid_'.$group_image->image->path) : null, - 'location' => [ - 'location' => rtrim($group->location), - 'country' => Fixometer::getCountryFromCountryCode($group->country_code), - 'country_code' => $group->country_code, - 'distance' => $distance, - ], - 'next_event' => $event ? $event->event_date_local : null, - 'all_restarters_count' => $group->all_restarters_count, - 'all_hosts_count' => $group->all_hosts_count, - 'all_confirmed_restarters_count' => $group->all_confirmed_restarters_count, - 'all_confirmed_hosts_count' => $group->all_confirmed_hosts_count, - 'networks' => \Illuminate\Support\Arr::pluck($group->networks, 'id'), - 'group_tags' => $group->group_tags->pluck('id'), - 'group_tags_full' => $group->group_tags->map(function($tag) { - return [ - 'id' => $tag->id, - 'name' => $tag->tag_name, - 'network_id' => $tag->network_id, - ]; - }), - 'following' => in_array($group->idgroups, $your_groupids), - 'nearby' => in_array($group->idgroups, $nearby_groupids), - 'archived_at' => $group->archived_at ? Carbon::parse($group->archived_at)->toIso8601String() : null - ]; - } - } - - return $ret; - } - public static function stats($id, $format = 'row') { $group = Group::where('idgroups', $id)->first(); @@ -647,36 +620,13 @@ public function ajaxDeleteImage($group_id, $id, $path) } /** - * [confirmCodeInvite description]. - * - * @author Christopher Kelker - @date 2019-03-25 - * @editor Christopher Kelker - * @version 1.0.0 - * @param [type] $code - * @return [type] + * Shareable-code deep link (Group::shareable_link). Thin redirector into the SPA (F2-5): + * POST /api/v2/invites/claim (App\Http\Controllers\API\AuthController::claimShareableCode) + * now owns validating the code and claiming it - including the unknown-code 404 - so there's + * no DB read, Invite row, or session-array bookkeeping left to do here. */ public function confirmCodeInvite(Request $request, $code): RedirectResponse { - // Variables - $group = Group::where('shareable_code', $code)->first(); - $hash = substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24); - - // Validate a record exists with the Group code - if (empty($group)) { - abort(404); - } - - // Create a new Invite record - Invite::create([ - 'record_id' => $group->idgroups, - 'email' => '', - 'hash' => $hash, - 'type' => 'group', - ]); - - // Push this into a session variable to find by the Group prefix - session()->push('groups.'.$code, $hash); - - return redirect('/user/register')->with('auth-for-invitation', __('auth.login_before_using_shareable_link', ['login_url' => url('/login')])); + return redirect(rtrim(config('restarters.frontend_url'), '/').'/group/invite/'.$code); } } diff --git a/app/Http/Controllers/GroupTagsController.php b/app/Http/Controllers/GroupTagsController.php index 9a616d6225..aa6446238c 100644 --- a/app/Http/Controllers/GroupTagsController.php +++ b/app/Http/Controllers/GroupTagsController.php @@ -2,85 +2,34 @@ namespace App\Http\Controllers; -use Illuminate\Http\RedirectResponse; use App\GroupTags; use App\Helpers\Fixometer; +use App\Http\Resources\Tag; use Auth; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; class GroupTagsController extends Controller { - public function index() + /** + * Render the global group-tags admin page (a Vue SPA that talks to + * /api/v2/group-tags). Network-scoped tags are managed elsewhere. + */ + public function index($editId = null) { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $all_tags = GroupTags::all(); - - return view('tags.index', [ - 'title' => __('group-tags.title'), - 'tags' => $all_tags, - ]); - } - - public function postCreateTag(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $name = $request->input('tag-name'); - $description = $request->input('tag-description'); - - $group_tag = GroupTags::create([ - 'tag_name' => $name, - 'description' => $description, - ]); - - return Redirect::to('tags/edit/'.$group_tag->id)->with('success', __('group-tags.create_success')); - } - - public function getEditTag($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } + $user = Auth::user(); - $tag = GroupTags::find($id); - - return view('tags.edit', [ - 'title' => __('group-tags.edit_tag'), - 'tag' => $tag, - ]); - } - - public function postEditTag($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { + if (! Fixometer::hasRole($user, 'Administrator')) { return redirect('/user/forbidden'); } - $name = $request->input('tag-name'); - $description = $request->input('tag-description'); + $tags = GroupTags::global()->orderBy('tag_name', 'asc')->get(); + $tagsForVue = $tags->map(fn ($tag) => (new Tag($tag))->toArray(request()))->values(); - GroupTags::find($id)->update([ - 'tag_name' => $name, - 'description' => $description, + return view('tags.index', [ + 'title' => __('group-tags.title'), + 'tags' => $tags, + 'tagsForVue' => $tagsForVue, + 'apiToken' => $user->api_token, + 'editId' => $editId !== null ? (int) $editId : null, ]); - - return Redirect::back()->with('success', __('group-tags.update_success')); - } - - public function getDeleteTag($id): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - GroupTags::find($id)->delete(); - - return Redirect::to('/tags')->with('success', __('group-tags.delete_success')); } } diff --git a/app/Http/Controllers/MapsProxyController.php b/app/Http/Controllers/MapsProxyController.php index e5d7f3f923..12de79b489 100644 --- a/app/Http/Controllers/MapsProxyController.php +++ b/app/Http/Controllers/MapsProxyController.php @@ -13,6 +13,20 @@ private function apiKey(): string return config('GOOGLE_API_CONSOLE_KEY') ?? env('GOOGLE_API_CONSOLE_KEY', ''); } + /** + * @OA\Get( + * path="/api/v2/maps/autocomplete", + * operationId="mapsAutocompletev2", + * tags={"Maps"}, + * summary="Proxy for Google Places Autocomplete (keeps the API key server-side)", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="input", required=true, in="query", @OA\Schema(type="string")), + * @OA\Parameter(name="types", required=false, in="query", @OA\Schema(type="string", default="geocode")), + * @OA\Response(response=200, description="Verbatim Google Places Autocomplete response"), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=422, description="Validation failure") + * ) + */ public function autocomplete(Request $request): JsonResponse { $request->validate(['input' => 'required|string']); @@ -26,6 +40,19 @@ public function autocomplete(Request $request): JsonResponse return response()->json($response->json()); } + /** + * @OA\Get( + * path="/api/v2/maps/place-details", + * operationId="mapsPlaceDetailsv2", + * tags={"Maps"}, + * summary="Proxy for Google Places Details (keeps the API key server-side)", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="place_id", required=true, in="query", @OA\Schema(type="string")), + * @OA\Response(response=200, description="Verbatim Google Places Details response"), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=422, description="Validation failure") + * ) + */ public function placeDetails(Request $request): JsonResponse { $request->validate(['place_id' => 'required|string']); diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php deleted file mode 100644 index ef39106cce..0000000000 --- a/app/Http/Controllers/NetworkController.php +++ /dev/null @@ -1,188 +0,0 @@ -hasRole('NetworkCoordinator') && ! $user->hasRole('Administrator')) { - abort(403); - } - - $yourNetworks = $user->networks->sortBy('name'); - $allNetworks = []; - $showAllNetworks = false; - - if ($user->hasRole('Administrator')) { - $showAllNetworks = true; - $allNetworks = Network::orderBy('name')->get(); - } - - return view('networks.index', [ - 'yourNetworks' => $yourNetworks, - 'allNetworks' => $allNetworks, - 'showAllNetworks' => $showAllNetworks, - ]); - } - - /** - * Display the specified network. - */ - public function show(Network $network): View - { - $user = Auth::user(); - - $this->authorize('view', $network); - - $groupsForAssociating = []; - - if ($user->can('associateGroups', $network)) { - $groupsForAssociating = $network->groupsNotIn()->sortBy('name'); - } - - // Get network stats - $stats = $network->stats(); - $stats['groups'] = $network->groups->count(); - - // Determine if user can manage tags (NC for this network or Admin) - $canManageTags = Fixometer::hasRole($user, 'Administrator') || - ($user->isCoordinatorOf($network)); - - // Get tags for this network - $tags = []; - if ($canManageTags) { - $tags = GroupTags::forNetwork($network->id) - ->get() - ->map(function ($tag) { - return [ - 'id' => $tag->id, - 'name' => $tag->tag_name, - 'description' => $tag->description, - 'groups_count' => $tag->groupTagGroups()->count(), - ]; - }); - } - - // Prepare network data for Vue component - $networkData = [ - 'id' => $network->id, - 'name' => $network->name, - 'description' => $network->description, - 'website' => $network->website, - 'logo' => $network->sizedLogo('_x100'), - 'coordinators' => $network->coordinators->map(function ($c) { - $profile = $c->getProfile($c->id); - $path = $profile ? $profile->path : null; - return [ - 'id' => $c->id, - 'name' => $c->name, - 'picture' => $path ? '/uploads/thumbnail_' . $path : '/images/placeholder-avatar.png', - ]; - }), - ]; - - return view('networks.show', [ - 'network' => $network, - 'networkData' => $networkData, - 'groupsForAssociating' => $groupsForAssociating, - 'stats' => $stats, - 'tags' => $tags, - 'canManageTags' => $canManageTags, - 'canAssociateGroups' => $user->can('associateGroups', $network), - 'apiToken' => $user->api_token, - ]); - } - - /** - * Show the form for editing the specified resource. - */ - public function edit(Network $network): View - { - $this->authorize('update', $network); - - return view('networks.edit', [ - 'network' => $network, - ]); - } - - /** - * Update the specified resource in storage. - */ - public function update(Request $request, Network $network): RedirectResponse - { - $this->authorize('update', $network); - - if ($request->hasFile('network_logo')) { - if (! config('restarters.features.image_upload')) { - return redirect()->route('networks.edit', [$network]) - ->withWarning('Image uploads are disabled on this site.'); - } - - // Determine the correct disk to use (s3 on Fly, public_uploads in dev) - $disk = config('filesystems.default') === 's3' ? 's3' : 'public_uploads'; - - // Save the file. - $path = $request->file('network_logo')->store('network_logos', [ - 'disk' => $disk, - ]); - - // Store it in the network object. - if ($path) { - // Generate the _x100 sized version by copying the file - $sizedPath = preg_replace('/\.([^.\s]{3,4})$/', '-_x100.$1', $path); - $storage = Storage::disk($disk); - - // Copy the uploaded file to the _x100 filename - $storage->copy($path, $sizedPath); - - $network->logo = $path; - $network->save(); - } else { - abort(500, 'Failed to save logo'); - } - } - - return redirect()->route('networks.edit', [$network]); - } - - /** - * Associate groups to the specified network. - */ - public function associateGroup(Request $request, Network $network): RedirectResponse - { - $this->authorize('associateGroups', $network); - - $groupIds = $request->input('groups'); - - if (is_null($groupIds)) { - return redirect()->route('networks.show', [$network])->withWarning(Lang::get('networks.show.add_groups_warning_none_selected')); - } - - foreach ($groupIds as $groupId) { - $group = Group::find($groupId); - $network->addGroup($group); - } - - $numberOfGroups = count($groupIds); - - return redirect()->route('networks.show', [$network])->withSuccess(Lang::choice('networks.show.add_groups_success', $numberOfGroups, ['number' => $numberOfGroups])); - } -} diff --git a/app/Http/Controllers/OutboundController.php b/app/Http/Controllers/OutboundController.php index 8645fc51fe..9365313935 100644 --- a/app/Http/Controllers/OutboundController.php +++ b/app/Http/Controllers/OutboundController.php @@ -11,7 +11,43 @@ class OutboundController extends Controller { /** type can be either party or group * id is id of group or party to display. - * */ + * + * @OA\Get( + * path="/api/outbound/info/{type}/{id}/{format}", + * operationId="outboundInfo", + * tags={"Outbound"}, + * summary="Get CO2-impact stats for a group/event, formatted for embeddable share widgets", + * description="Public endpoint, no authentication required. Used from external share plugins/widgets (e.g. group and event share buttons on third-party sites) to render CO2-saving comparisons (equivalent to driving/watching TV/manufacturing cars or sofas). When called under /api/*, returns JSON; when called outside the API prefix it instead renders a Blade view (outbound.info or visualisations) - only the API/JSON behaviour is relevant to this spec. Returns 404 if id is not numeric, or if the group/party cannot be found.", + * @OA\Parameter( + * name="type", in="path", required=true, + * description="Which kind of entity id refers to.", + * @OA\Schema(type="string", enum={"party","group"}) + * ), + * @OA\Parameter( + * name="id", in="path", required=true, + * description="Numeric ID of the party (event) or group to report on.", + * @OA\Schema(type="integer") + * ), + * @OA\Parameter( + * name="format", in="path", required=false, + * description="Output shape. 'fixometer' (default) returns a car/TV comparison plus a manufacturing comparison. 'consume', 'manufacture' and 'leaf' return a single title/measure/equal_to comparison sized for a specific widget.", + * @OA\Schema(type="string", enum={"fixometer","consume","manufacture","leaf"}, default="fixometer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation. Shape depends on format: 'fixometer' returns {info, co2}; all other formats return {format, co2, title, measure, equal_to}.", + * @OA\JsonContent( + * @OA\Property(property="info", type="object", description="Only present when format=fixometer.", nullable=true), + * @OA\Property(property="format", type="string", nullable=true, description="Echoes the requested format; only present when format is not fixometer."), + * @OA\Property(property="co2", type="number", description="Total CO2 (kg) saved by the group/event."), + * @OA\Property(property="title", type="string", nullable=true), + * @OA\Property(property="measure", type="string", nullable=true), + * @OA\Property(property="equal_to", nullable=true, description="Numeric or formatted-string comparison value, depending on format.") + * ) + * ), + * @OA\Response(response=404, description="id is not numeric, or the referenced party/group does not exist.") + * ) + */ public static function info($type, $id, $format = 'fixometer', $return = 'view') { diff --git a/app/Http/Controllers/PartyController.php b/app/Http/Controllers/PartyController.php index da01ced133..4aac48c77d 100644 --- a/app/Http/Controllers/PartyController.php +++ b/app/Http/Controllers/PartyController.php @@ -715,8 +715,16 @@ public function postSendInvite(Request $request): RedirectResponse return redirect()->back()->with('warning', __('events.invite_noemails')); } + /** + * Emailed deep link (App\Http\Controllers\API\EventAttendanceController:300). The status-hash + * DB update is frozen server-side (F2-5) - it's idempotent and this is the only writer - but + * the final destination is now the SPA's party page rather than the Blade one, with a query + * flag instead of session flash (a redirect out of this app can't carry Laravel session state). + */ public function confirmInvite($event_id, $hash): RedirectResponse { + $frontend = rtrim(config('restarters.frontend_url'), '/'); + $user_event = EventsUsers::where('status', $hash)->where('event', $event_id)->first(); if (! empty($user_event)) { @@ -727,11 +735,11 @@ public function confirmInvite($event_id, $hash): RedirectResponse $this->notifyHostsOfRsvp($user_event, $event_id); - return redirect('/party/view/'.$user_event->event); + return redirect($frontend.'/party/view/'.$user_event->event.'?joined=1'); } \Sentry\CaptureMessage(__('events.invite_invalid')); - return redirect('/party/view/'.intval($event_id))->with('warning', __('events.invite_invalid')); + return redirect($frontend.'/party/view/'.intval($event_id).'?invite=invalid'); } public function cancelInvite($event_id): RedirectResponse @@ -856,36 +864,13 @@ public function deleteEvent($id): RedirectResponse } /** - * [confirmCodeInvite description]. - * - * @author Christopher Kelker - @date 2019-03-25 - * @editor Christopher Kelker - * @version 1.0.0 - * @param [type] $code - * @return [type] + * Shareable-code deep link (Party::shareable_link). Thin redirector into the SPA (F2-5): + * POST /api/v2/invites/claim (App\Http\Controllers\API\AuthController::claimShareableCode) + * now owns validating the code and claiming it - including the unknown-code 404 - so there's + * no DB read, Invite row, or session-array bookkeeping left to do here. */ public function confirmCodeInvite(Request $request, $code): RedirectResponse { - // Variables - $party = Party::where('shareable_code', $code)->first(); - $hash = substr(bin2hex(openssl_random_pseudo_bytes(32)), 0, 24); - - // Validate a record exists with the Event code - if (empty($party)) { - abort(404); - } - - // Create a new Invite record - Invite::create([ - 'record_id' => $party->idevents, - 'email' => '', - 'hash' => $hash, - 'type' => 'event', - ]); - - // Push this into a session variable to find by the Event prefix - session()->push('events.'.$code, $hash); - - return redirect('/user/register')->with('auth-for-invitation', __('auth.login_before_using_shareable_link', ['login_url' => url('/login')])); + return redirect(rtrim(config('restarters.frontend_url'), '/').'/party/invite/'.$code); } } diff --git a/app/Http/Controllers/PreviewDeployController.php b/app/Http/Controllers/PreviewDeployController.php deleted file mode 100644 index 17d00a1079..0000000000 --- a/app/Http/Controllers/PreviewDeployController.php +++ /dev/null @@ -1,81 +0,0 @@ -get("https://api.github.com/repos/" . self::REPO . "/pulls", [ - 'state' => 'open', - 'per_page' => 50, - ]); - - if ($response->successful()) { - $prs = collect($response->json())->map(fn($pr) => [ - 'number' => $pr['number'], - 'title' => $pr['title'], - 'branch' => $pr['head']['ref'], - 'author' => $pr['user']['login'], - ])->all(); - } else { - $error = 'Could not fetch PRs from GitHub (status ' . $response->status() . ')'; - } - } else { - $error = 'GITHUB_DEPLOY_PAT is not configured. Set it as a Fly secret on restarters-dev.'; - } - - return view('admin.preview-deploy', compact('prs', 'error')); - } - - public function deploy(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $branch = $request->input('branch'); - - if (! $branch) { - return back()->withErrors(['branch' => 'Please select a branch.']); - } - - $token = config('services.github.deploy_pat'); - - if (! $token) { - return back()->withErrors(['token' => 'GITHUB_DEPLOY_PAT is not configured.']); - } - - $response = Http::withToken($token) - ->post("https://api.github.com/repos/" . self::REPO . "/actions/workflows/" . self::WORKFLOW . "/dispatches", [ - 'ref' => 'develop', - 'inputs' => ['branch' => $branch], - ]); - - if ($response->successful()) { - return back()->with('success', "Deploy of \"$branch\" triggered. Build takes ~15 minutes. Watch: https://github.com/" . self::REPO . "/actions"); - } - - return back()->withErrors(['deploy' => 'GitHub API error: ' . $response->status() . ' — ' . $response->body()]); - } -} diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php deleted file mode 100644 index 6f98bd2ee0..0000000000 --- a/app/Http/Controllers/RoleController.php +++ /dev/null @@ -1,89 +0,0 @@ -set('title', 'Roles'); - // $this->set('roleList', $this->Role->findAll()); - - $Role = new Role; - $roleList = $Role->findAll(); - - // Prepare data for Vue table - $tableData = []; - foreach ($roleList as $role) { - $tableData[] = [ - 'id' => $role->id, - 'role' => $role->role, - 'permissions_list' => $role->permissions_list, - ]; - } - - return view('role.all', [//role.index - 'title' => 'Roles', - 'roleList' => $roleList, - 'tableData' => $tableData, - ]); - } - - return redirect(RouteServiceProvider::HOME); - } - - public function edit($id, Request $request): View - { - $user = Auth::user(); - - if (Fixometer::hasRole($user, 'Administrator')) { - $role = Role::where('idroles', $id)->first(); - - if ($request->getMethod() == 'POST') { - $permissions = $request->get('permissions'); - $formid = (int) substr(strrchr($request->get('formId'), '_'), 1); - - $update = $role->edit($formid, $permissions); - if (! $update) { - $response['danger'] = 'Something went wrong. Could not update the permissions.'; - \Sentry\CaptureMessage($response['danger']); - } else { - $response['success'] = 'Permissions for this Role have been updated.'; - } - } - - $permissionsList = $role->rolePermissions($role->idroles); - $activePerms = []; - foreach ($permissionsList as $p) { - $activePerms[] = $p->permission; - } - - if (! isset($response)) { - $response = null; - } - - return view('role.edit', [ - 'response' => $response, - 'title' => 'Edit '.$role->role.' Role', - 'formId' => $role->idroles, - 'permissions' => $role->permissions(), - 'activePermissions' => $activePerms, - 'role_name' => $role->role, - ]); - } - } -} diff --git a/app/Http/Controllers/SkillsController.php b/app/Http/Controllers/SkillsController.php deleted file mode 100644 index 89331d84ff..0000000000 --- a/app/Http/Controllers/SkillsController.php +++ /dev/null @@ -1,91 +0,0 @@ - 'Skills', - 'skills' => $all_skills, - ]); - } - - public function postCreateSkill(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $skill = Skills::create([ - 'skill_name' => $request->input('skill_name'), - 'description' => $request->input('skill_desc'), - ]); - - return Redirect::to('skills/edit/'.$skill->id)->with('success', __('skills.create_success')); - } - - public function getEditSkill($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $skill = Skills::find($id); - - return view('skills.edit', [ - 'title' => 'Edit Skill', - 'skill' => $skill, - ]); - } - - public function postEditSkill($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - Skills::find($id)->update([ - 'skill_name' => $request->input('skill-name'), - 'category' => $request->input('category'), - 'description' => $request->input('skill-description'), - ]); - - return Redirect::back()->with('success', __('skills.update_success')); - } - - public function getDeleteSkill($id): RedirectResponse - { - - // Are you an admin? - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - // If we have permission, let's delete - $skill = Skills::find($id)->delete(); - - // We can only delete the data in the pivot table if the delete was successful - if ($skill == 1) { - UsersSkills::where('skill', $id)->delete(); - } - - // Then redirect back - return Redirect::to('/skills')->with('success', __('skills.delete_success')); - } -} diff --git a/app/Http/Controllers/TusController.php b/app/Http/Controllers/TusController.php new file mode 100644 index 0000000000..9096e819bc --- /dev/null +++ b/app/Http/Controllers/TusController.php @@ -0,0 +1,97 @@ +serve(); + + // Tus is a cross-origin-friendly protocol by design (resumable uploads from + // browser JS); make sure the handful of custom response headers clients need + // to read (to resume/complete an upload) are actually exposed to them. + $response->headers->set( + 'Access-Control-Expose-Headers', + 'Location, Upload-Offset, Upload-Length, Upload-Expires, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size, Tus-Checksum-Algorithm' + ); + + return $response; + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 7368f67b62..94f9194233 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -15,9 +15,7 @@ use App\Helpers\Fixometer; use App\Http\Controllers\PartyController; use App\Invite; -use App\Network; use App\Notifications\AdminNewUser; -use App\Notifications\ResetPassword; use App\Party; use App\Permissions; use App\Preferences; @@ -392,120 +390,30 @@ public function postAdminEdit(Request $request): RedirectResponse return redirect()->back()->with('message', __('profile.admin_success')); } - public function recover(Request $request): View + /** + * Thin redirector into the SPA (F2-5): password recovery is now owned end-to-end by + * POST /api/v2/auth/password/forgot (App\Http\Controllers\API\AuthController::forgotPasswordv2), + * which is what the SPA's /user/recover page submits to. This GET-only route exists so any + * old bookmarks/links to /user/recover still land somewhere useful. + */ + public function recover(): RedirectResponse { - $User = new User; - - $email = $request->get('email'); - - if ($request->getMethod() == 'POST' && $email) { - if (empty($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { - $response['danger'] = __('passwords.invalid'); - - // Don't log to Sentry - legitimate user error. - } else { - $user = $User->where('email', $email)->first(); - - if (! empty($user)) { - $id = $user->id; - $data = []; - - // generate recovery code - $bytes = 32; - $data['recovery'] = substr(bin2hex(openssl_random_pseudo_bytes($bytes)), 0, 24); - - // add date timestamp - $data['recovery_expires'] = strftime('%Y-%m-%d %X', time() + (24 * 60 * 60)); - - // update record - $user->update([ - 'recovery' => $data['recovery'], - 'recovery_expires' => $data['recovery_expires'], - ]); - - User::find($id)->notify(new ResetPassword([ - 'url' => url('/user/reset?recovery='.$data['recovery']), - ])); - - $response['success'] = __('passwords.sent'); - } else { - $response['danger'] = __('passwords.user'); - - // Don't log to Sentry - legitimate user error. - } - } - - return view('auth.forgot-password', [//user.recover - 'title' => __('passwords.recover_title'), - 'response' => $response, - ]); - } - - return view('auth.forgot-password', [//user.recover - 'title' => __('passwords.recover_title'), - ]); + return redirect(rtrim(config('restarters.frontend_url'), '/').'/user/recover'); } - public function reset(Request $request) + /** + * Thin redirector into the SPA (F2-5): the emailed recovery link + * (App\Http\Controllers\API\AuthController::forgotPasswordv2) still points at this Laravel + * URL so it keeps working unchanged, but submission is now owned end-to-end by + * POST /api/v2/auth/password/reset (resetPasswordv2), which the SPA's /user/reset page + * (client/app/pages/user/reset.vue) calls. + */ + public function reset(Request $request): RedirectResponse { - $User = new User; - $user = null; - - $recovery = $request->recovery; - - if (!$recovery) { - $valid_code = false; - } else { - $recovery = filter_var($recovery, FILTER_SANITIZE_STRING); - $user = $User->where('recovery', '=', $recovery)->first(); - - if (is_object($user) && strtotime($user->recovery_expires) > time()) { - $valid_code = true; - } else { - $valid_code = false; - } - } - - $pwd = $request->post('password'); - $cpwd = $request->post('confirm_password'); - $response = null; - $email = null; - - if ($request->getMethod() == 'POST' && $pwd && $cpwd) { - if (!$valid_code) { - $response['danger'] = __('passwords.token'); - \Sentry\CaptureMessage($response['danger']); - } elseif ($pwd !== $cpwd) { - $response['danger'] = __('passwords.match'); - - // Don't log to Sentry - legitimate user error. - } else { - $email = $user->email; - $oldPassword = $user->password; - - $update = $user->update([ - 'password' => Hash::make($pwd), - ]); - - if ($update) { - event(new PasswordChanged($user, $oldPassword)); - return redirect('login')->with('success', __('passwords.updated')); - } else { - $response['danger'] = __('passwords.failed'); - \Sentry\CaptureMessage($response['danger']); - } - } - } else { - $email = $user ? $user->email : null; - } + $frontend = rtrim(config('restarters.frontend_url'), '/'); + $recovery = $request->query('recovery'); - return view('auth.reset-password', [ - 'title' => 'Account recovery', - 'recovery' => $recovery, - 'valid_code' => $valid_code, - 'response' => $response, - 'email' => $email, - ]); + return redirect($frontend.'/user/reset'.($recovery ? '?recovery='.urlencode($recovery) : '')); } public function all() @@ -836,27 +744,19 @@ public function logout(): RedirectResponse return redirect('/login'); } - public function getRegister($hash = null) + /** + * Thin redirector into the SPA (F2-4): registration is now owned end-to-end by the SPA + * (client/app/pages/user/register.vue + login.vue), which POSTs to /api/v2/auth/register + * and understands invite_hash the same way this legacy hash param used to. The emailed + * invite links that carry $hash (App\Http\Controllers\API\GroupMembershipController:272, + * EventAttendanceController:336, EventController:214) still point at this Laravel URL, so + * they keep working unchanged - only what happens when they're opened has changed. + */ + public function getRegister($hash = null): RedirectResponse { - if (Auth::check() && Auth::user()->hasUserGivenConsent()) { - return redirect('dashboard'); - } - - $stats = Fixometer::loginRegisterStats(); - $deviceCount = array_key_exists(0, $stats['device_count_status']) ? $stats['device_count_status'][0]->counter : 0; + $frontend = rtrim(config('restarters.frontend_url'), '/'); - $activeRepairNetworkId = session()->get('repair_network'); - $network = Network::find($activeRepairNetworkId); - $showNewsletterSignup = $network->shortname == 'restarters'; - - return view('auth.register-new', [ - 'skills' => Fixometer::allSkills(), - 'co2Total' => $stats['waste_stats'][0]->powered_footprint + $stats['waste_stats'][0]->unpowered_footprint, - 'wasteTotal' => $stats['waste_stats'][0]->powered_waste + $stats['waste_stats'][0]->unpowered_waste, - 'partiesCount' => $stats['partiesCount'], - 'deviceCount' => $deviceCount, - 'showNewsletterSignup' => $showNewsletterSignup, - ]); + return redirect($frontend.'/user/register'.($hash ? '?invite_hash='.urlencode($hash) : '')); } public function postRegister(Request $request, $hash = null): RedirectResponse diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 2dee170c53..2ec31d58b9 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -20,6 +20,7 @@ class Kernel extends HttpKernel \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, \App\Http\Middleware\TrustProxies::class, + \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\HttpsProtocol::class, ]; @@ -46,7 +47,6 @@ class Kernel extends HttpKernel \App\Http\Middleware\VerifyTranslationAccess::class, ], 'api' => [ - \App\Http\Middleware\AddCorsHeaders::class, \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], @@ -69,10 +69,6 @@ class Kernel extends HttpKernel 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verifyUserConsent' => \App\Http\Middleware\VerifyUserConsent::class, - 'AcceptUserInvites' => \App\Http\Middleware\AcceptUserInvites::class, - 'ensureAPIToken' => \App\Http\Middleware\EnsureAPIToken::class, - /**** OTHER MIDDLEWARE ****/ 'localize' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRoutes::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, diff --git a/app/Http/Middleware/AcceptUserInvites.php b/app/Http/Middleware/AcceptUserInvites.php deleted file mode 100644 index 25b1d42cf7..0000000000 --- a/app/Http/Middleware/AcceptUserInvites.php +++ /dev/null @@ -1,96 +0,0 @@ -session()->get('groups') || ! empty($request->session()->get('events')))) { - $request->session()->put('invites-feedback'); - } else { - $request->session()->forget('invites-feedback'); - } - - if (! empty($request->session()->get('groups'))) { - foreach ($request->session()->get('groups') as $hashs) { - foreach ($hashs as $hash) { - $acceptance = Invite::where('hash', $hash)->firstOrFail(); - $group = $acceptance->group; - - // If the $acceptance type is a Group - // and the User has not already joined. - // Accept or Update a record and - // delete the Invite and create a new session - if ($acceptance->type == 'group' && ! $group->isVolunteer()) { - UserGroups::updateOrCreate([ - 'user' => auth()->id(), - 'group' => $acceptance->record_id, - 'status' => '1', - 'role' => 4, - ]); - $acceptance->delete(); - $request->session()->push('invites-feedback', __('groups.you_have_joined', [ - 'url' => url("/group/view/{$group->idgroups}"), - 'name' => $group->name - ])); - - // Else that must mean the User is already part of the Group. - // We can then delete the Invite and create a new session - } else { - $request->session()->push('invites-feedback', 'You are already a member of session()->get('events'))) { - foreach ($request->session()->get('events') as $hashs) { - foreach ($hashs as $hash) { - $acceptance = Invite::where('hash', $hash)->firstOrFail(); - $event = $acceptance->event; - - // If the $acceptance type is a Event - // and the User has not already joined. - // Accept or Update a record and - // delete the Invite and create a new session - if ($acceptance->type == 'event' && ! $event->isVolunteer()) { - EventsUsers::updateOrCreate([ - 'user' => auth()->id(), - 'event' => $acceptance->record_id, - 'status' => '1', - 'role' => 4, - ]); - $acceptance->delete(); - $request->session()->push('invites-feedback', __('events.you_have_joined', [ - 'url' => url("/party/view/{$event->idevents}"), - 'name' => $event->venue - ])); - - // Else that must mean the User is already part of the Event. - // We can then delete the Invite and create a new session - } else { - $request->session()->push('invites-feedback', 'You are already a member of getMethod() === 'OPTIONS') { - return response('', 200) - ->header('Access-Control-Allow-Origin', '*') - ->header('Access-Control-Allow-Methods', 'GET, OPTIONS') - ->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); - } - - $response = $next($request); - $response->headers->set('Access-Control-Allow-Origin', '*'); - $response->headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS'); - - return $response; - } -} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index d4ef6447a9..17b292984d 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -12,6 +12,8 @@ class Authenticate extends Middleware */ protected function redirectTo(Request $request): ?string { - return $request->expectsJson() ? null : route('login'); + // The login page lives in the SPA; Auth::routes() (and with it the + // 'login' route name) is gone. + return $request->expectsJson() ? null : rtrim(config('restarters.frontend_url'), '/').'/login'; } } diff --git a/app/Http/Middleware/AuthenticateRedirectingToFrontend.php b/app/Http/Middleware/AuthenticateRedirectingToFrontend.php new file mode 100644 index 0000000000..8319e24a59 --- /dev/null +++ b/app/Http/Middleware/AuthenticateRedirectingToFrontend.php @@ -0,0 +1,22 @@ +fullUrl()); + } +} diff --git a/app/Http/Middleware/EnsureAPIToken.php b/app/Http/Middleware/EnsureAPIToken.php deleted file mode 100644 index 6be9294091..0000000000 --- a/app/Http/Middleware/EnsureAPIToken.php +++ /dev/null @@ -1,35 +0,0 @@ -ensureAPIToken(); - - // Return the API token as a cookie. This means it can be picked up by the Vue client. - $response = $next($request); - - if (method_exists($response, 'withCookie')) { - $response = $response->withCookie(cookie()->forever('restarters_apitoken', $token, null, null, false, false)); - } - - return $response; - } else { - return $next($request); - } - } -} diff --git a/app/Http/Middleware/VerifyUserConsent.php b/app/Http/Middleware/VerifyUserConsent.php deleted file mode 100644 index d89f183a2c..0000000000 --- a/app/Http/Middleware/VerifyUserConsent.php +++ /dev/null @@ -1,24 +0,0 @@ -hasUserGivenConsent()) { - return $next($request); - } else { - return redirect('/user/register'); - } - } -} diff --git a/app/Http/Middleware/VerifyUserConsentApi.php b/app/Http/Middleware/VerifyUserConsentApi.php new file mode 100644 index 0000000000..774443813d --- /dev/null +++ b/app/Http/Middleware/VerifyUserConsentApi.php @@ -0,0 +1,44 @@ +method(), ['GET', 'HEAD', 'OPTIONS'], true)) { + return $next($request); + } + + if ($request->is('api/v2/auth/*') || $request->is('api/v2/session')) { + return $next($request); + } + + $user = Auth::user() ?? auth('sanctum')->user() ?? auth('api')->user(); + + if ($user && ! $user->hasUserGivenConsent()) { + return response()->json([ + 'message' => 'Data consent is required before making changes.', + 'reason' => 'consent_required', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Resources/Brand.php b/app/Http/Resources/Brand.php new file mode 100644 index 0000000000..13ac62d2c8 --- /dev/null +++ b/app/Http/Resources/Brand.php @@ -0,0 +1,38 @@ + $this->id, + 'brand_name' => $this->brand_name, + ]; + } +} diff --git a/app/Http/Resources/BrandCollection.php b/app/Http/Resources/BrandCollection.php new file mode 100644 index 0000000000..e0a707c703 --- /dev/null +++ b/app/Http/Resources/BrandCollection.php @@ -0,0 +1,27 @@ + $this->idcategories, 'name' => $this->name, - 'powered' => $this->powered, + 'powered' => $this->powered !== null ? (bool) $this->powered : null, + 'weight' => $this->weight !== null ? (float) $this->weight : null, + 'footprint' => $this->footprint !== null ? (float) $this->footprint : null, + 'footprint_reliability' => $this->footprint_reliability !== null ? (int) $this->footprint_reliability : null, + 'cluster' => $this->cluster !== null ? (int) $this->cluster : null, + 'cluster_name' => $this->cluster_name ?? null, + 'description_short' => $this->description_short, ]; } } diff --git a/app/Http/Resources/CategoryCollection.php b/app/Http/Resources/CategoryCollection.php new file mode 100644 index 0000000000..bc8004bee2 --- /dev/null +++ b/app/Http/Resources/CategoryCollection.php @@ -0,0 +1,27 @@ + $this->idgroups, 'name' => $this->name, 'image' => $this->groupImage && is_object($this->groupImage) && is_object($this->groupImage->image) ? $this->groupImage->image->path : null, + // groupImage() is a HasOne onto Xref itself (App\Group::groupImage()), so its own + // idxref is exactly the id DELETE /api/v2/groups/{id}/images/{idimages} expects - + // mirrors the Image resource's 'idxref' field used for the same purpose on + // devices/events (see app/Http/Resources/Image.php). + 'image_idxref' => $this->groupImage && is_object($this->groupImage) ? $this->groupImage->idxref : null, 'website' => $this->website, 'phone' => $this->phone, 'description' => $this->free_text, @@ -301,15 +386,45 @@ public function toArray(Request $request): array 'tags' => new TagCollection($this->resource->getFilteredTagsForUser()), 'timezone' => $this->timezone, 'approved' => $this->approved ? true : false, + // getAutoApproveAttribute() (App\Group) is already in the model's $appends, but + // toArray() here is hand-built rather than delegating to it, so it needs pulling in + // explicitly. EventForm.vue needs this to pick the right "before you submit" copy. + 'auto_approve' => (bool) $this->resource->auto_approve, 'network_data' => $networkData, 'full' => true, 'email' => $this->email, - 'archived_at' => $this->archived_at ? Carbon::parse($this->archived_at)->toIso8601String() : null + 'archived_at' => $this->archived_at ? Carbon::parse($this->archived_at)->toIso8601String() : null, + 'discourse_group' => $this->discourse_group, ]; $ret['hosts'] = $this->resource->all_confirmed_hosts_count; $ret['restarters'] = $this->resource->all_confirmed_restarters_count; + $ret['shareable_link'] = $this->resource->shareable_link; + + // Same auth-resolution order as API\GroupController::getGroupv2() (this endpoint is not + // behind auth:api, so the request may be anonymous, session-authenticated, or bearer/API + // token authenticated). + $user = \Illuminate\Support\Facades\Auth::user() + ?? auth('sanctum')->user() + ?? auth('api')->user(); + $ret['is_member'] = $user ? $this->resource->isVolunteer($user->id) : null; + + // group/view.blade.php shows a banner when the caller has an + // outstanding invitation to this group, so someone who navigates here + // directly - rather than through the emailed link - can still accept. + // The value is the users_groups.status column, which doubles as the + // invite hash in /group/accept-invite/{group}/{hash}; a joined member + // has status '1'. Only ever the caller's own invite, and absent + // entirely when there isn't one. + $ret['has_pending_invite'] = $user + ? (\App\UserGroups::where('group', $this->idgroups) + ->where('user', $user->id) + ->where('status', '<>', '1') + ->whereNotNull('status') + ->value('status') ?: null) + : null; + // Get next approved event for group $nextevent = \App\Group::find($this->idgroups)->getNextUpcomingEvent(); diff --git a/app/Http/Resources/GroupSummary.php b/app/Http/Resources/GroupSummary.php index b4af5cf306..c8ba56c1dc 100644 --- a/app/Http/Resources/GroupSummary.php +++ b/app/Http/Resources/GroupSummary.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; +use Cache; /** * @OA\Schema( @@ -50,6 +51,17 @@ * ) * ), * @OA\Property( + * property="group_tags_full", + * title="group_tags_full", + * description="Tags on this group. Only present on calls which load them, e.g. the summary list.", + * type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="network_id", type="integer", nullable=true), + * ) + * ), + * @OA\Property( * property="updated_at", * title="updated_at", * description="The last change to this group. This includes changes which affect the stats.", @@ -63,6 +75,18 @@ * ref="#/components/schemas/EventSummary" * ), * @OA\Property( + * property="hosts", + * title="hosts", + * description="The number of hosts of this group (if requested via API call flag).", + * type="number", + * ), + * @OA\Property( + * property="restarters", + * title="hosts", + * description="The number of restarters in this group (if requested via API call flag).", + * type="number", + * ), + * @OA\Property( * property="summary", * title="summary", * description="Indicates that this is a summary result, not full group information.", @@ -91,32 +115,74 @@ public function toArray(Request $request): array 'image' => $this->groupImage && is_object($this->groupImage) && is_object($this->groupImage->image) ? $this->groupImage->image->path : null, 'location' => new GroupLocation($this), 'networks' => new NetworkSummaryCollection($this->resource->networks), + // Tags drive the badges and the tag filter on the groups list. + // Only included when the caller eager-loaded them, so other users + // of this resource don't pick up an N+1. + 'group_tags_full' => $this->whenLoaded('group_tags', function () { + return $this->resource->group_tags->map(function ($tag) { + return [ + 'id' => $tag->id, + 'name' => $tag->tag_name, + 'network_id' => $tag->network_id, + ]; + }); + }), 'updated_at' => Carbon::parse($this->updated_at)->toIso8601String(), 'archived_at' => $this->archived_at ? Carbon::parse($this->archived_at)->toIso8601String() : null, 'summary' => true ]; + if ($request->get('includeCounts', false)) { + $ret['hosts'] = $this->resource->all_confirmed_hosts_count; + $ret['restarters'] = $this->resource->all_confirmed_restarters_count; + } + if ($request->get('includeNextEvent', false)) { - // Get next approved event for group. - $nextevent = \App\Group::find($this->idgroups)->getNextUpcomingEvent(); + // Get next approved event for group. We cache all upcoming events to speed up the case where we + // are fetching many groups. + if (Cache::has('future_approved_events')) { + $upcoming = Cache::get('future_approved_events'); + } else { + // approved only: the base intent (Group::getNextUpcomingEvent filters + // where approved=true), which this bulk-cached rewrite had dropped - + // an unapproved, not-yet-public event could surface as a group's + // next event on the map/summary (RES-1995 / PR 887). + $future = \App\Party::future()->where('approved', true)->get(); + + // Can't serialise the whole event, and we only need a few fields. + $upcoming = []; + + foreach ($future as $event) { + $upcoming[] = [ + 'id' => $event->idevents, + 'group_id' => $event->group, + 'start' => $event->event_start_utc, + 'end' => $event->event_end_utc, + 'timezone' => $event->timezone, + 'title' => $event->venue ?? $event->location, + 'location' => $event->location, + 'online' => $event->online, + 'lat' => $event->latitude, + 'lng' => $event->longitude, + 'updated_at' => $event->updated_at->toIso8601String(), + 'summary' => true + ]; + } - if ($nextevent) { - // Using the resource for the nested event causes infinite loops. Just add the model attributes we - // need directly. - $ret['next_event'] = [ - 'id' => $nextevent->idevents, - 'start' => $nextevent->event_start_utc, - 'end' => $nextevent->event_end_utc, - 'timezone' => $nextevent->timezone, - 'title' => $nextevent->venue ?? $nextevent->location, - 'location' => $nextevent->location, - 'online' => $nextevent->online, - 'lat' => $nextevent->latitude, - 'lng' => $nextevent->longitude, - 'updated_at' => $nextevent->updated_at->toIso8601String(), - 'summary' => true - ]; + Cache::put('future_approved_events', $upcoming, 60); } + + // Find the next event for this group. + $nextevent = null; + + foreach ($upcoming as $event) { + if ($event['group_id'] == $this->idgroups) { + $nextevent = $event; + break; + } + } + + $ret['next_event'] = $nextevent; } return($ret); diff --git a/app/Http/Resources/Network.php b/app/Http/Resources/Network.php index a9cbbd655e..59a76333b6 100644 --- a/app/Http/Resources/Network.php +++ b/app/Http/Resources/Network.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -199,6 +200,17 @@ * title="updated_at", * description="The last change to this network. This includes changes which affect the stats.", * format="date-time", + * ), + * @OA\Property( + * property="coordinators", + * title="coordinators", + * description="The coordinators of this network.", + * type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string"), + * @OA\Property(property="avatar_url", type="string", nullable=true) + * ) * ) * ) */ @@ -220,7 +232,22 @@ public function toArray(Request $request): array 'default_language' => $this->default_language, 'stats' => $this->resource->stats(), 'timezone' => $this->resource->timezone, - 'full' => true + 'full' => true, + // Legacy NetworkController@show built this the same way (User:: + // getProfile() thumbnail lookup) for the "Network Coordinators" + // section on resources/js/components/NetworkPage.vue - this + // Resource had never carried it, so the Nuxt page dropped the + // section entirely (docs/nuxt-migration/api-gaps.md Phase E). + 'coordinators' => $this->coordinators->map(function ($coordinator) use ($request) { + $profile = User::getProfile($coordinator->id); + $avatarUrl = ($profile && $profile->path) ? ($request->root() . '/uploads/thumbnail_' . $profile->path) : null; + + return [ + 'id' => (int) $coordinator->id, + 'name' => $coordinator->name, + 'avatar_url' => $avatarUrl, + ]; + })->values(), ]; } } diff --git a/app/Http/Resources/NetworkSummary.php b/app/Http/Resources/NetworkSummary.php index 6a63d7e94d..e68c707552 100644 --- a/app/Http/Resources/NetworkSummary.php +++ b/app/Http/Resources/NetworkSummary.php @@ -33,6 +33,13 @@ * example="/mid_1597853610178a4b76e4d666b2a7b32ee75d8a24c706f1cbf213970.png" * ), * @OA\Property( + * property="description", + * title="description", + * description="HTML description of the network.", + * format="string", + * example="

This is a description.

" + * ), + * @OA\Property( * property="summary", * title="summary", * description="Indicates that this is a summary result, not full network information.", @@ -53,6 +60,7 @@ public function toArray(Request $request): array 'id' => $this->id, 'name' => $this->name, 'logo' => $this->logo ? ($request->root() . '/uploads/' . $this->logo) : null, + 'description' => $this->description, 'summary' => true ]; } diff --git a/app/Http/Resources/Party.php b/app/Http/Resources/Party.php index e79eb523cb..4414baaf67 100644 --- a/app/Http/Resources/Party.php +++ b/app/Http/Resources/Party.php @@ -2,9 +2,11 @@ namespace App\Http\Resources; +use App\Helpers\Fixometer; use Illuminate\Http\Request; use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Auth; /** * @OA\Schema( @@ -226,6 +228,38 @@ * format="date-time", * ), * @OA\Property( + * property="attending", + * title="attending", + * description="Whether the authenticated user is attending this event (EventsUsers.status==='1'). Omitted when the caller is not authenticated.", + * format="boolean", + * example="true", + * nullable=true + * ), + * @OA\Property( + * property="images", + * title="images", + * description="Any photos uploaded for this event", + * type="array", + * @OA\Items( + * ref="#/components/schemas/Image" + * ) + * ), + * @OA\Property( + * property="shareable_link", + * title="shareable_link", + * description="A link that can be shared to let people join this event directly, without an email invite. Only present when the authenticated user has permission to edit the event - it is shown in the host-only invite modal.", + * format="string", + * example="https://app.restarters.net/party/invite/abc123" + * ), + * @OA\Property( + * property="discourse_thread", + * title="discourse_thread", + * description="The id of this event's linked Discourse discussion thread. Combine with the session config's discourse_url as {discourse_url}/t/{discourse_thread} to link to the thread. Only present when the authenticated user is a confirmed attendee (EventsUsers.status==='1', same check as 'attending') - unlike Group.discourse_group this is not public. Null when there is no linked thread, the caller is unauthenticated, or the caller isn't a confirmed attendee.", + * format="string", + * nullable=true, + * example="4821" + * ), + * @OA\Property( * property="full", * title="full", * description="Indicates that this is a full result, not summary group information.", @@ -267,8 +301,39 @@ public function toArray(Request $request): array 'approved' => $this->approved ? true : false, 'network_data' => $networkData, 'full' => true, + 'images' => \App\Http\Resources\Image::collection($this->resource->getImages()), + // develop's GroupEventScrollTable shows an invited-volunteer count + // (PartyController.php:76's allinvitedcount), which the moderation + // queue needs. whenCounted, NOT a direct ->count(): this resource is + // used by list endpoints, and counting per row there would be an + // N+1. Endpoints that want it call loadCount('allInvited') - one + // query for the whole collection - and it is simply absent + // elsewhere, exactly as before. + 'invited' => $this->whenCounted('allInvited'), ]; + // Mirrors expandEvent()'s Auth::user() && $event->isBeingAttendedBy(...) check (strict + // status==='1'). Optional-auth chain matches Volunteer resource / API\EventController::getUser(). + $currentUser = Auth::user() ?? auth('sanctum')->user() ?? auth('api')->user(); + $isAttending = $currentUser !== null && $this->resource->isBeingAttendedBy($currentUser->id); + $ret['attending'] = $this->when($currentUser !== null, fn () => $isAttending); + + // The invite modal's "invite via shareable link" tab (events.shareable_link). + // Gated on the same permission that guards actually sending invites + // (EventAttendanceController::invitesv2), because that tab is only ever + // rendered to hosts. Group.shareable_link is unconditional by contrast - + // there, the link grants exactly what the public join button already + // does, so there is nothing to gate. + $ret['shareable_link'] = $this->when( + $currentUser !== null && Fixometer::userHasEditPartyPermission($this->idevents, $currentUser->id), + fn () => $this->resource->shareable_link + ); + + // events/view.blade.php: $discourseThread = ($is_attending && $event->discourse_thread) ? + // ... : null - gated the same way as 'attending' above, unlike Group.discourse_group which + // is unconditionally public. + $ret['discourse_thread'] = ($isAttending && $this->discourse_thread) ? $this->discourse_thread : null; + if ($this->link) { // Don't return this unless present - the OpenAPI schema doesn't allow null values. $ret['link'] = $this->link; diff --git a/app/Http/Resources/PartySummary.php b/app/Http/Resources/PartySummary.php index 2ed021d79d..3fa5cb2d56 100644 --- a/app/Http/Resources/PartySummary.php +++ b/app/Http/Resources/PartySummary.php @@ -95,6 +95,13 @@ * format="date-time", * ), * @OA\Property( + * property="stats", + * title="stats", + * description="An array of statistics about the activity of an event. See Event.stats for the full field list.", + * format="object", + * ref="#/components/schemas/Event/properties/stats" + * ), + * @OA\Property( * property="summary", * title="summary", * description="Indicates that this is a summary result, not full group information.", @@ -128,6 +135,7 @@ public function toArray(Request $request): array 'lng' => $this->longitude, 'group' => \App\Http\Resources\GroupSummary::make($this->theGroup), 'updated_at' => $this->updated_at->toIso8601String(), + 'stats' => $this->resource->getEventStats(), 'summary' => true ]; } diff --git a/app/Http/Resources/Permission.php b/app/Http/Resources/Permission.php new file mode 100644 index 0000000000..1aa82183df --- /dev/null +++ b/app/Http/Resources/Permission.php @@ -0,0 +1,26 @@ + (int) $this['id'], + 'name' => $this['name'], + ]; + } +} diff --git a/app/Http/Resources/RoleAdmin.php b/app/Http/Resources/RoleAdmin.php new file mode 100644 index 0000000000..0e845a9bfe --- /dev/null +++ b/app/Http/Resources/RoleAdmin.php @@ -0,0 +1,40 @@ + (int) $this['id'], + 'name' => $this['name'], + 'permissions' => array_map('intval', $this['permissions']), + 'permissions_list' => $this['permissions_list'] ?? '', + ]; + } +} diff --git a/app/Http/Resources/Skill.php b/app/Http/Resources/Skill.php index 7dd62cea1a..8623525710 100644 --- a/app/Http/Resources/Skill.php +++ b/app/Http/Resources/Skill.php @@ -18,8 +18,8 @@ * example=1 * ), * @OA\Property( - * property="name", - * title="name", + * property="skill_name", + * title="skill_name", * description="Name of this skill", * format="string", * example="First aid" @@ -29,14 +29,16 @@ * title="description", * description="Optional description of this skill", * format="string", - * example="This is for qualified First Aiders to identify themselves to event organisers" + * example="This is for qualified First Aiders to identify themselves to event organisers", + * nullable=true * ), * @OA\Property( * property="category", * title="category", - * description="Category of this skill", + * description="Category of this skill (1 = Organising, 2 = Technical; see Fixometer::skillCategories())", * format="int64", - * example=1 + * example=1, + * nullable=true * ), * ) */ @@ -50,9 +52,9 @@ public function toArray(Request $request): array { return [ 'id' => $this->id, - 'name' => $this->skill_name, + 'skill_name' => $this->skill_name, 'description' => $this->description, - 'category' => $this->category, + 'category' => $this->category !== null ? (int) $this->category : null, ]; } } diff --git a/app/Http/Resources/SkillCollection.php b/app/Http/Resources/SkillCollection.php index 80d412f4a3..8725327847 100644 --- a/app/Http/Resources/SkillCollection.php +++ b/app/Http/Resources/SkillCollection.php @@ -19,6 +19,8 @@ class SkillCollection extends ResourceCollection { + public $collects = \App\Http\Resources\Skill::class; + /** * Transform the resource collection into an array. */ diff --git a/app/Http/Resources/UserAdmin.php b/app/Http/Resources/UserAdmin.php new file mode 100644 index 0000000000..d77553632c --- /dev/null +++ b/app/Http/Resources/UserAdmin.php @@ -0,0 +1,60 @@ +resource, 'lastLogin') ? $this->resource->lastLogin() : null; + + return [ + 'id' => (int) $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'role' => (int) $this->role, + 'role_name' => $this->role_name ?? (string) $this->getRoleName(), + 'location' => $this->location, + 'country' => $this->country_code, + 'country_name' => $this->country_code ? Fixometer::getCountryFromCountryCode($this->country_code) : null, + 'groups_count' => (int) ($this->groups_count ?? $this->groups()->count()), + 'created_at' => optional($this->created_at)?->toIso8601String(), + 'last_login_at' => optional($lastLogin)?->toIso8601String(), + ]; + } + + private function getRoleName(): string + { + $roleNames = [ + \App\Role::ROOT => 'Root', + \App\Role::ADMINISTRATOR => 'Administrator', + \App\Role::NETWORK_COORDINATOR => 'NetworkCoordinator', + \App\Role::HOST => 'Host', + \App\Role::RESTARTER => 'Restarter', + ]; + + return $roleNames[$this->role] ?? ''; + } +} diff --git a/app/Http/Resources/Volunteer.php b/app/Http/Resources/Volunteer.php index ad841dd92e..ed1c84aa99 100644 --- a/app/Http/Resources/Volunteer.php +++ b/app/Http/Resources/Volunteer.php @@ -120,7 +120,7 @@ public function toArray(Request $request): array // Only include email when the authenticated user is a group host, network coordinator, or admin // Check both web and API authentication - $currentUser = Auth::user() ?? auth('api')->user(); + $currentUser = Auth::user() ?? auth('sanctum')->user() ?? auth('api')->user(); if ($currentUser) { $isAdmin = Fixometer::hasRole($currentUser, 'Administrator'); $isHost = Fixometer::userIsHostOfGroup($this->group, $currentUser->id); diff --git a/app/OpenApi/AlertInput.php b/app/OpenApi/AlertInput.php new file mode 100644 index 0000000000..94710605f3 --- /dev/null +++ b/app/OpenApi/AlertInput.php @@ -0,0 +1,69 @@ +The site will be down briefly this evening.

" + * ), + * @OA\Property( + * property="ctatitle", + * type="string", + * maxLength=255, + * nullable=true, + * description="Optional call-to-action button label.", + * example="Learn more" + * ), + * @OA\Property( + * property="ctalink", + * type="string", + * format="uri", + * nullable=true, + * description="Optional call-to-action button URL.", + * example="https://therestartproject.org" + * ) + * ) + */ +class AlertInput +{ +} diff --git a/app/OpenApi/Responses.php b/app/OpenApi/Responses.php new file mode 100644 index 0000000000..49866862e8 --- /dev/null +++ b/app/OpenApi/Responses.php @@ -0,0 +1,68 @@ +undeleted(); - $query = $query->where('event_start_utc', '>', date('Y-m-d H:i:s'))->orderBy('event_start_utc','ASC'); + // reorder() before orderBy: a parent scope may already have applied an + // ORDER BY (e.g. DESC), and orderBy() APPENDS rather than replaces, so + // without this we get "ORDER BY event_start_utc DESC, event_start_utc + // ASC" - the DESC wins and future() returns the LAST future event first, + // making a "next event" lookup pick the wrong one (RES-1995 / PR 887). + $query = $query->where('event_start_utc', '>', date('Y-m-d H:i:s')) + ->reorder()->orderBy('event_start_utc', 'ASC'); return $query; } @@ -630,7 +636,10 @@ public function getEventStartTimestampAttribute() public function getShareableLinkAttribute() { if (! empty($this->shareable_code)) { - return url("party/invite/{$this->shareable_code}"); + // Points at the SPA, matching Group::getShareableLinkAttribute(); + // the claim happens via POST /api/v2/invites/claim (or the + // invite_code param on login/register). + return rtrim(config('restarters.frontend_url'), '/')."/party/invite/{$this->shareable_code}"; } return ''; @@ -651,6 +660,22 @@ public function isBeingAttendedBy($userId) ])->exists(); } + // Set by callers that batch-load images to avoid N+1 (see FixometerFile::findImagesForMany). + public ?array $preloadedImages = null; + + // Mirrors Device::getImages() - event photos are a gallery (multiple per event), same + // xref-backed images table the upload/delete endpoints already use. + public function getImages() + { + if ($this->preloadedImages !== null) { + return $this->preloadedImages; + } + + $File = new \FixometerFile; + + return $File->findImages(env('TBL_EVENTS'), $this->idevents); + } + /** * [owner description] * Party Owner/Creator. @@ -688,8 +713,11 @@ public function scopeHasDevicesRepaired($query, int $has_x_devices_fixed = 1) public function scopeEventHasFinished($query) { - $now = Carbon::now(); - return $query->whereRaw("`event_end_utc` < '{$now}'"); + // Bound, not interpolated. $now is server-generated so this was never + // injectable, but it was the only interpolated raw SQL in the codebase + // - and one benign example is enough to make a grep for the dangerous + // pattern useless. + return $query->where('event_end_utc', '<', Carbon::now()); } public function getWastePreventedAttribute() diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 28d6afb502..50f24f20b0 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -31,6 +31,15 @@ public function delete(User $user, User $target): bool return $user->id == $target->id || Fixometer::hasRole($user, 'Administrator'); } + /** + * Determine whether the acting user may create new user accounts (mirrors the legacy + * "Administrators can add users" gate on UserController::create). + */ + public function create(User $user): bool + { + return Fixometer::hasRole($user, 'Administrator'); + } + /** * Determine whether one user can change the Repair Directory role of another to a specific value. * diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4f620ea837..9378760597 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use Cache; use Illuminate\Support\ServiceProvider; use Illuminate\Translation\Translator; +use Laravel\Sanctum\Sanctum; use OwenIt\Auditing\Models\Audit; use Schema; @@ -45,6 +46,14 @@ public function boot(): void */ public function register(): void { + // Use our own copy of Sanctum's personal_access_tokens migration + // (database/migrations, guarded with Schema::hasTable) instead of the + // vendor one. Production had the table created out-of-band without a + // migrations row, so the unguarded vendor migration re-ran and hit + // "1050 Table already exists"; the guarded copy is a no-op when the + // table is already present but still creates it on fresh installs. + Sanctum::ignoreMigrations(); + $this->app->singleton(Geocoder::class, function () { return new Geocoder(); }); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index ceb5f81774..8fab7dd843 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -51,6 +51,12 @@ public function boot(): void return Limit::perMinute(300)->by($request->user()?->id ?: $request->ip()); }); + // Login/register/password endpoints. Env-configurable so test runs + // (Playwright bursts) can relax it without editing source files. + RateLimiter::for('auth', function (Request $request) { + return Limit::perMinute((int) config('restarters.auth_rate_limit'))->by($request->ip()); + }); + $this->routes(function () { $this->mapApiRoutes(); $this->mapWebRoutes(); diff --git a/app/Skills.php b/app/Skills.php index 5aca694fd0..92d6792852 100644 --- a/app/Skills.php +++ b/app/Skills.php @@ -3,10 +3,13 @@ namespace App; use DB; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Skills extends Model { + use HasFactory; + protected $table = 'skills'; /** * The attributes that are mass assignable. diff --git a/app/SsoTicket.php b/app/SsoTicket.php new file mode 100644 index 0000000000..a2f0921f4b --- /dev/null +++ b/app/SsoTicket.php @@ -0,0 +1,61 @@ + $user->id, + 'ticket_hash' => hash('sha256', $plaintext), + 'expires_at' => now()->addSeconds(self::LIFETIME_SECONDS), + ]); + + return $plaintext; + } + + /** + * Consume a ticket: returns the user it was issued to, or null if the + * ticket is unknown, expired, or already used. Single-use. + */ + public static function consume(?string $plaintext): ?User + { + if (! $plaintext) { + return null; + } + + $ticket = self::where('ticket_hash', hash('sha256', $plaintext))->first(); + + if (! $ticket || $ticket->used_at || $ticket->expires_at->isPast()) { + return null; + } + + $ticket->used_at = now(); + $ticket->save(); + + return User::find($ticket->user_id); + } + + protected $casts = [ + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + ]; +} diff --git a/app/User.php b/app/User.php index cc03afc130..83e8442e62 100644 --- a/app/User.php +++ b/app/User.php @@ -25,6 +25,7 @@ class User extends Authenticatable implements Auditable, HasLocalePreference use HasFactory; use Notifiable; use SoftDeletes; + use \Laravel\Sanctum\HasApiTokens; use \OwenIt\Auditing\Auditable; // Use the Authorizable trait so that we can call can() on a user to evaluation policies. use \Illuminate\Foundation\Auth\Access\Authorizable; @@ -343,6 +344,47 @@ public function scopeNearbyRestarters($query, $latitude, $longitude, $radius = 2 ->having('distance', '<=', $radius); } + /** + * Look up a user by recovery token, but only when the token has not expired. Shared by + * AuthController::resetPasswordv2 (which consumes the token) and ::recoveryInfov2 (which + * previews it for the reset-password page on load), so the validity check can't drift + * between the two. + */ + public static function findByValidRecoveryToken(string $token): ?self + { + $user = self::where('recovery', $token)->first(); + + if (! $user || strtotime($user->recovery_expires) <= time()) { + return null; + } + + return $user; + } + + /** + * Stamp the given consent columns with "now", and opt the user into the newsletter when + * requested. Shared by AuthController::registerv2 and ::consentv2, which each stamp their + * own (different) set of consent_* columns but must capture the newsletter checkbox + * identically. + * + * @param string[] $consentColumns consent_* column names to stamp, e.g. ['consent_gdpr', 'consent_future_data'] + * @param bool $newsletter when true, opts the user in; false/omitted leaves any existing + * value untouched (registerv2's brand-new row already defaults newsletter to 0 in the + * DB migration, so this is behaviour-preserving there too) + */ + public function recordConsent(array $consentColumns, bool $newsletter = false): void + { + $timestamp = date('Y-m-d H:i:s'); + + foreach ($consentColumns as $column) { + $this->$column = $timestamp; + } + + if ($newsletter) { + $this->newsletter = 1; + } + } + /* * * This allows us to check whether consent has been provided - couples with custom middleware diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 11687e2174..0000000000 --- a/babel.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {presets: ['@babel/preset-env']} \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000000..b89cd34dd9 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,28 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Test coverage +coverage + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example +test-results diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000000..91a4251124 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,17 @@ +# Nuxt client container. Dev mode (NUXT_DEV_MODE=true, the docker-compose +# default) runs the HMR dev server against the bind-mounted source; otherwise +# it serves a production build — which is what CI's Playwright job tests. +FROM node:22-bookworm-slim + +WORKDIR /app + +ENV NUXT_HOST=0.0.0.0 \ + NUXT_PORT=3000 \ + PORT=3000 + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000000..1e2846c00f --- /dev/null +++ b/client/README.md @@ -0,0 +1,103 @@ +# Restarters client + +Nuxt 4 SPA that will become the entire restarters.net user interface, talking +to the Laravel app only through `/api/**`. See +`docs/nuxt-migration/design.md` (repo root) for the full design and +`plans/active/nuxt-migration.md` for the migration plan/status. + +## Requirements + +- Node 22 (matches the host toolchain used across the repo; no Docker + needed for this app - `npm`/`npx` run directly here). + +## Setup + +```bash +npm install +``` + +## Dev commands + +```bash +npm run dev # nuxt dev server, http://localhost:3000, HMR +npm run build # production build (nuxi build) -> .output/ +npm run generate # static generate (for the eventual Capacitor build) +npm run preview # preview a production build locally +npm run test # vitest run (unit tests, tests/) +npm run lint # eslint . (flat config, generated by @nuxt/eslint) +npm run lint:fix # eslint . --fix +``` + +`npx vitest` (watch mode) and `npx nuxi build` also work directly. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `NUXT_PUBLIC_API_BASE` | `http://localhost:8001` | Base URL of the Laravel API this app talks to (`runtimeConfig.public.apiBase`). | +| `NUXT_PUBLIC_IS_APP` | `false` | Set to `true` to flag a future Capacitor/WebView build (`runtimeConfig.public.isApp`). Not used yet. | + +Locale is not an env var: `@nuxtjs/i18n` detects the browser locale and +persists the user's choice (cookie/localStorage); logged-in users' choice is +also saved server-side via `PATCH /api/v2/session`. + +## Layout + +This is a Nuxt 4 project (`compatibilityVersion: 4`), so the app source +lives under `app/` (the default Nuxt 4 `srcDir`) and resolves via the `~`/`@` +aliases: + +``` +client/ + app/ + api/ BaseAPI.js (ofetch-based) + one API class per + resource (SessionAPI, AuthAPI, GroupAPI, EventAPI, + UserAPI, NetworkAPI) + index.js factory. Lives inside + app/ (rather than as a client/-root sibling) so it + resolves cleanly through the `~` alias everywhere else + in app/ - a deliberate adaptation of design.md §6's + layout diagram for Nuxt 4's srcDir default. + app.vue shell: + (BApp is bootstrap-vue-next's provider component - + toasts/modals/popovers need it mounted once at the root) + assets/css/ restarters.scss - placeholder brand stylesheet (imports + Bootstrap 5.3 for now; the Stylesheet stage replaces this + with the real brand layer seeded from resources/global/css) + layouts/ (empty - default/plain/bare layouts land with the Shell slice) + middleware/ auth.global.ts - reads definePageMeta({auth, role}) + pages/ index.vue placeholder; file-based routing fills in per + design.md §6.2's migration order + plugins/ api.ts - provides $api from app/api/index.js + stores/ auth.js (token + user, persists only the token) and + session.js (GET /api/v2/session bootstrap), both Pinia + options-style stores + i18n/locales/ en.json / fr.json / fr-BE.json - lazy-loaded by + @nuxtjs/i18n (default langDir, no extra config needed since + it resolves relative to client/i18n/, not app/) + tests/ vitest unit tests, mirroring app/ (api/, stores/, ...) + e2e/ (empty - Playwright specs land in the A14 plan task) + nuxt.config.ts ssr:false, runtimeConfig.public.{apiBase,isApp}, modules + vitest.config.ts happy-dom + @vue/test-utils, tests/setup.ts stubs the + Nuxt ambient macros (useRuntimeConfig, useNuxtApp, ...) + that source files reference as bare globals + eslint.config.mjs flat config, generated by the @nuxt/eslint module + (.nuxt/eslint.config.mjs) plus project overrides +``` + +## Conventions + +- **Pure SPA** (`ssr: false`): bootstrap-vue-next has no SSR support, and + everything the client needs arrives via the API - no server-rendered + Vue here. See design.md §6 for why. +- **Pinia stores are options-style** (`defineStore({ state, actions, + getters })`), matching the Freegle reference implementation + (`iznik-nuxt3`). Only `auth.token` is persisted to `localStorage` + (`pinia-plugin-persistedstate`, `pick: ['token']`). +- **API calls** go through `$api` (provided by `app/plugins/api.ts`), never + directly via `$fetch`/`ofetch` from a component or store body. Every + `API` class extends `BaseAPI`, which injects the `Authorization: + Bearer ` header when a token is present and a `locale` query param + on every request. A `401` from `/api/v2/session` clears the auth store; a + `401` from anywhere else does not (design.md §4.4). +- **`data-testid`** on every interactive element, added as components are + built. diff --git a/client/app/api/APIError.js b/client/app/api/APIError.js new file mode 100644 index 0000000000..b64f990364 --- /dev/null +++ b/client/app/api/APIError.js @@ -0,0 +1,20 @@ +/** + * Thrown by BaseAPI when a request fails. Carries enough of the failed + * request/response for callers to branch on status (e.g. 422 validation + * errors, 401 unauthenticated) without re-parsing the ofetch error shape. + */ +export default class APIError extends Error { + constructor({ status, data, path, method }) { + const message = + (data && (data.message || data.error)) || + `API request failed: ${method} ${path} (${status})` + + super(message) + + this.name = 'APIError' + this.status = status + this.data = data + this.path = path + this.method = method + } +} diff --git a/client/app/api/AlertsAPI.js b/client/app/api/AlertsAPI.js new file mode 100644 index 0000000000..466bb48782 --- /dev/null +++ b/client/app/api/AlertsAPI.js @@ -0,0 +1,24 @@ +import BaseAPI from './BaseAPI.js' + +/** + * Admin-configured informational alerts, shown as dismissible banners above + * the events list (design.md's Nuxt gap-closure pass). + * + * GET /api/v2/alerts (App\Http\Controllers\API\AlertController::listAlertsv2) + * - public, unauthenticated, cached server-side for 2h. Returns + * { data: [...] } of currently-active alerts (the server already filters to + * start <= now <= end); each alert is + * {id, title, html, ctatitle, ctalink, start, end, variant}. + * + * There is no per-user dismiss endpoint: PUT/PATCH on this resource + * (addAlertv2/updateAlertv2) are Administrator-only "create/edit this alert" + * actions, not a dismiss. The legacy client (resources/js/components/ + * AlertBanner.vue) dismissed purely client-side via localStorage, which + * components/alerts/AlertsBanner.vue reproduces - so this wrapper only needs + * the read side. + */ +export default class AlertsAPI extends BaseAPI { + list() { + return this.$get('/api/v2/alerts') + } +} diff --git a/client/app/api/AuthAPI.js b/client/app/api/AuthAPI.js new file mode 100644 index 0000000000..725f872451 --- /dev/null +++ b/client/app/api/AuthAPI.js @@ -0,0 +1,66 @@ +import BaseAPI from './BaseAPI.js' + +/** + * Login/register/logout + password reset + the invite-claim family + * (design.md §4.2). + */ +export default class AuthAPI extends BaseAPI { + login({ email, password, invite_code, invite_type, invite_hash }) { + return this.$post('/api/v2/auth/login', { + email, + password, + invite_code, + invite_type, + invite_hash, + }) + } + + register(payload) { + return this.$post('/api/v2/auth/register', payload) + } + + logout() { + return this.$post('/api/v2/auth/logout') + } + + forgotPassword({ email }) { + return this.$post('/api/v2/auth/password/forgot', { email }) + } + + // Records outstanding data consents (plus profile basics) for the current + // user - the completion flow VerifyUserConsentApi gates mutations on. + consent(payload) { + return this.$post('/api/v2/auth/consent', payload) + } + + resetPassword({ recovery, password, password_confirmation }) { + return this.$post('/api/v2/auth/password/reset', { + recovery, + password, + password_confirmation, + }) + } + + // Validates a recovery token before the reset form renders, and returns + // the account email so the user can confirm which account they're + // resetting (legacy reset-password.blade.php's $valid_code / fp_email). + recoveryInfo(token) { + return this.$get('/api/v2/auth/password/recovery/' + token) + } + + emailAvailable(email) { + return this.$get('/api/v2/auth/email-available', { email }) + } + + ssoTicket() { + return this.$post('/api/v2/auth/sso-ticket') + } + + claimInvite({ invite_code, invite_type, invite_hash }) { + return this.$post('/api/v2/invites/claim', { + invite_code, + invite_type, + invite_hash, + }) + } +} diff --git a/client/app/api/BaseAPI.js b/client/app/api/BaseAPI.js new file mode 100644 index 0000000000..551d8bb541 --- /dev/null +++ b/client/app/api/BaseAPI.js @@ -0,0 +1,87 @@ +import { ofetch } from 'ofetch' +import APIError from './APIError.js' + +/** + * Base class for API resource classes (SessionAPI, AuthAPI, ...). + * + * Construction is decoupled from Pinia/i18n so it can be unit-tested without + * a Nuxt app context: the plugin (plugins/api.ts) supplies live getters for + * the token/locale/unauthorized-callback, and tests can supply stub ones. + * + * config: + * base - API base URL (runtimeConfig.public.apiBase) + * getToken() - returns the current auth token, or null/undefined + * getLocale() - returns the current i18n locale code, or null/undefined + * onUnauthorized() - called when a 401 comes back from /session (see §4.4 + * client conventions: a 401 elsewhere must NOT log out) + */ +export default class BaseAPI { + constructor(config = {}) { + this.base = config.base + this.getToken = config.getToken || (() => null) + this.getLocale = config.getLocale || (() => null) + this.onUnauthorized = config.onUnauthorized || (() => {}) + } + + async $request(method, path, { params, body } = {}) { + const token = this.getToken() + const locale = this.getLocale() + + // Every API call carries the locale so the server's APISetLocale + // middleware can localize response strings (design.md §4 conventions). + const query = { ...(params || {}) } + if (locale) { + query.locale = locale + } + + const headers = {} + if (token) { + headers.Authorization = `Bearer ${token}` + } + + try { + return await ofetch(path, { + baseURL: this.base, + method, + query, + body, + headers, + }) + } catch (error) { + const status = error?.response?.status ?? error?.statusCode ?? null + const data = error?.response?._data ?? error?.data ?? null + + // Only /session's 401 means "you are no longer logged in" - a 401 on + // any other endpoint leaves the session intact (design.md §4.4). + if (status === 401 && this._isSessionPath(path)) { + this.onUnauthorized() + } + + throw new APIError({ status, data, path, method }) + } + } + + _isSessionPath(path) { + return path === '/api/v2/session' || path.startsWith('/api/v2/session?') + } + + $get(path, params) { + return this.$request('GET', path, { params }) + } + + $post(path, body) { + return this.$request('POST', path, { body }) + } + + $patch(path, body) { + return this.$request('PATCH', path, { body }) + } + + $put(path, body) { + return this.$request('PUT', path, { body }) + } + + $del(path, body) { + return this.$request('DELETE', path, { body }) + } +} diff --git a/client/app/api/BrandAPI.js b/client/app/api/BrandAPI.js new file mode 100644 index 0000000000..7999c5f260 --- /dev/null +++ b/client/app/api/BrandAPI.js @@ -0,0 +1,30 @@ +import BaseAPI from './BaseAPI.js' + +/** + * /api/v2/brands (API\BrandController, already implemented server-side - + * design.md §6.2 Phase D task D4, PR #863's BrandsPage.vue is the + * functional spec). List/get are public; create/update/delete require + * Administrator (403 JSON otherwise) - enforced server-side, the + * Administrator-only page gating is UX-level only (design.md §4.4). + */ +export default class BrandAPI extends BaseAPI { + list() { + return this.$get('/api/v2/brands') + } + + get(id) { + return this.$get(`/api/v2/brands/${id}`) + } + + create(payload) { + return this.$post('/api/v2/brands', payload) + } + + update(id, payload) { + return this.$put(`/api/v2/brands/${id}`, payload) + } + + del(id) { + return this.$del(`/api/v2/brands/${id}`) + } +} diff --git a/client/app/api/CategoryAPI.js b/client/app/api/CategoryAPI.js new file mode 100644 index 0000000000..eb48f4173c --- /dev/null +++ b/client/app/api/CategoryAPI.js @@ -0,0 +1,32 @@ +import BaseAPI from './BaseAPI.js' + +/** + * /api/v2/categories + /api/v2/category-clusters (API\CategoryController, + * already implemented server-side - design.md §6.2 Phase D task D4, PR + * #863's CategoriesPage.vue is the functional spec). Unlike its siblings + * (brands/skills/group-tags) this resource is list+update ONLY - no + * create/delete route exists server-side (confirmed by reading + * routes/api.php: only GET '/', GET '{id}' and PUT '{id}' under + * /categories), matching the legacy CategoriesPage.vue's + * `:allow-create="false" :allow-delete="false"`. List/get/clusters are + * public; update requires Administrator. + */ +export default class CategoryAPI extends BaseAPI { + list() { + return this.$get('/api/v2/categories') + } + + get(id) { + return this.$get(`/api/v2/categories/${id}`) + } + + update(id, payload) { + return this.$put(`/api/v2/categories/${id}`, payload) + } + + // GET /api/v2/category-clusters - {id, name}[], populates the cluster + // dropdown on the edit form. + clusters() { + return this.$get('/api/v2/category-clusters') + } +} diff --git a/client/app/api/ConfigAPI.js b/client/app/api/ConfigAPI.js new file mode 100644 index 0000000000..f858e8b286 --- /dev/null +++ b/client/app/api/ConfigAPI.js @@ -0,0 +1,27 @@ +import BaseAPI from './BaseAPI.js' + +/** + * Thin client for small, unauthenticated, non-v2 config endpoints that + * don't fit the resource-per-model classes. `timezones()` backs + * GroupForm.vue's timezone field (GroupTimeZone.vue is the functional + * spec) - GET /api/timezones already exists (ApiController::timezones) and + * predates the v2 API/{data:...} envelope, so it returns a bare array. + */ +export default class ConfigAPI extends BaseAPI { + timezones() { + return this.$get('/api/timezones') + } + + // GET /api/homepage_data (api-contracts-phase-c.md C6b, EXISTS, v1, + // unauthenticated, 12h server cache). Embedded as-is at + // therestartproject.org, so its shape is frozen - no {data:...} envelope, + // the body IS the payload: {participants, hours_volunteered, items_fixed, + // waste_powered, waste_unpowered, waste_total, co2_powered, co2_unpowered, + // co2_total, fixed_powered, fixed_unpowered, total_powered, + // total_unpowered, ...legacy aliases}. Backs components/fixometer/ + // ImpactStats.vue (resources/js/components/FixometerGlobalImpact.vue is + // the functional spec). + homepageData() { + return this.$get('/api/homepage_data') + } +} diff --git a/client/app/api/DashboardAPI.js b/client/app/api/DashboardAPI.js new file mode 100644 index 0000000000..e7d1407ee3 --- /dev/null +++ b/client/app/api/DashboardAPI.js @@ -0,0 +1,13 @@ +import BaseAPI from './BaseAPI.js' + +/** + * The dashboard's single bootstrap call (api-contracts-phase-b.md B1): + * your groups, nearby groups, newly-added nearby groups and upcoming events + * in one shape. Endpoint does not exist server-side yet (B1 is a separate + * plan row) - client code is built against the documented contract. + */ +export default class DashboardAPI extends BaseAPI { + fetch() { + return this.$get('/api/v2/dashboard') + } +} diff --git a/client/app/api/DeviceAPI.js b/client/app/api/DeviceAPI.js new file mode 100644 index 0000000000..593226f28c --- /dev/null +++ b/client/app/api/DeviceAPI.js @@ -0,0 +1,98 @@ +import BaseAPI from './BaseAPI.js' + +/** + * Device CRUD + supporting metadata lookups (api-contracts-phase-c.md C5; + * design.md §6.2 C5 task brief). Device CRUD already exists server-side + * (API\DeviceController::createDevicev2/updateDevicev2/deleteDevicev2) - + * read validateDeviceParams() for the exact payload shape this mirrors + * (eventid/category/item_type/brand/model/age/estimate/problem/notes/ + * repair_status/next_steps/spare_parts/barrier). Both create and update + * require `eventid` in the body (not just the route for update), and both + * return a bare {id, device, stats} (not {data: ...}) - same convention as + * EventAPI.create/update. + */ +export default class DeviceAPI extends BaseAPI { + create(payload) { + return this.$post('/api/v2/devices', payload) + } + + update(id, payload) { + return this.$patch(`/api/v2/devices/${id}`, payload) + } + + del(id) { + return this.$del(`/api/v2/devices/${id}`) + } + + // GET /api/v2/devices/options (api-contracts-phase-c.md C5, NEW) - + // {data: {barriers: [{id, name}], spare_parts: [...], next_steps: [...]}}. + // Item-type suggestions and brands are deliberately NOT duplicated here - + // see itemTypes()/brands() below, both reusing existing endpoints per the + // contract's explicit "reuse, don't duplicate" instruction. + options() { + return this.$get('/api/v2/devices/options') + } + + // GET /api/v2/items (EXISTS) - {type, powered, idcategories, + // categoryname}[]. The entire data source for the item-type autocomplete + // and the category-suggestion port + // (composables/useDeviceCategorySuggestion.js). + itemTypes() { + return this.$get('/api/v2/items') + } + + // GET /api/v2/categories (EXISTS) - flat Category[] with cluster/ + // cluster_name joined per row. Grouped client-side into the + // {id, name, categories[]} shape DeviceForm's category - so an + // unscoped `&:read-only` greyed out every .form-control select on the site + // (Country, Year of birth, the Skills listbox) as though disabled, where + // develop renders them white. Caught by comparing 31-profile-edit. + &:disabled, + &:is(input, textarea):read-only { + background-color: $input-disabled-bg; + opacity: 1; + } +} + +.form-control-lg { + height: 45px !important; +} + +// Non-select2