From 0c72d3b458ceafcf7e0d49a9cea66a1aa70ab3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20L=2E?= Date: Sun, 5 Apr 2026 17:49:35 +0100 Subject: [PATCH 1/3] export data, fix unsubscribe notification --- CLAUDE.md | 1 + app/Actions/Teams/DownloadTeamDataAction.php | 13 +- .../Commands/SendEmailToSubscribed.php | 8 +- app/Exports/CreateCSVExport.php | 100 ++++++- app/Http/Controllers/API/TeamsController.php | 22 +- app/Jobs/Emails/DispatchEmail.php | 6 + app/Providers/AppServiceProvider.php | 7 +- readme/API.md | 27 +- readme/ExportData.md | 252 +++++++++++++++++ resources/js/stores/teams/index.js | 8 +- resources/js/views/Teams/TeamsHub.vue | 71 ++++- .../User/Uploads/components/UploadsHeader.vue | 38 +++ .../Feature/Email/V5AnnouncementEmailTest.php | 15 + tests/Feature/Teams/DownloadTeamDataTest.php | 62 ++++- tests/Unit/Exports/CreateCSVExportTest.php | 260 +++++++++++++++++- 15 files changed, 840 insertions(+), 50 deletions(-) create mode 100644 readme/ExportData.md diff --git a/CLAUDE.md b/CLAUDE.md index e54da94f1..b8950ce56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,7 @@ Fully deployed. 1010+ tests passing. Facilitator queue (3-panel admin-like UI fo - `readme/Privacy.md` — Privacy Policy source content with GDPR legal basis (Vue component renders this) - `readme/Twitter.md` — Automated Twitter/X commands (schedule, data sources, tweet format, Browsershot config) - `readme/TagSuggestions.md` — Quick tags sync API (mobile presets, bulk-replace, cross-device sync) +- `readme/ExportData.md` — CSV data export system (user/team/location exports, column layout, date filters, S3 pipeline) ## Daily Changelog After every change in a session, append a one-line entry to `readme/changelog/YYYY-MM-DD.md` (create the file if it doesn't exist for today's date). Group entries by session. This is the running record of all work done each day. diff --git a/app/Actions/Teams/DownloadTeamDataAction.php b/app/Actions/Teams/DownloadTeamDataAction.php index 5c38aa291..d1ba17164 100644 --- a/app/Actions/Teams/DownloadTeamDataAction.php +++ b/app/Actions/Teams/DownloadTeamDataAction.php @@ -10,16 +10,21 @@ class DownloadTeamDataAction { - public function run(User $user, Team $team) + public function run(User $user, Team $team, array $dateFilter = []) { $path = now()->format('Y') . "/" . now()->format('m') . "/" . now()->format('d') . - "/" . now()->getTimestamp() . - '/_Team_OpenLitterMap.csv'; // 2020/10/25/unix/ + "/" . now()->getTimestamp(); + + if (!empty($dateFilter)) { + $path .= '_from_' . $dateFilter['fromDate'] . '_to_' . $dateFilter['toDate']; + } + + $path .= '/_Team_OpenLitterMap.csv'; /* Dispatch job to create CSV file for export */ - (new CreateCSVExport(null, null, $team->id)) + (new CreateCSVExport(null, null, $team->id, null, $dateFilter)) ->queue($path, 's3', null, ['visibility' => 'public']) ->chain([ // These jobs are executed when above is finished. diff --git a/app/Console/Commands/SendEmailToSubscribed.php b/app/Console/Commands/SendEmailToSubscribed.php index 576d82a58..ce344dbc6 100644 --- a/app/Console/Commands/SendEmailToSubscribed.php +++ b/app/Console/Commands/SendEmailToSubscribed.php @@ -37,9 +37,9 @@ public function handle(): int $userCount = User::where('emailsub', 1)->count(); - // Subscribers not already in users table (avoid duplicates) - $userEmails = User::where('emailsub', 1)->pluck('email')->toArray(); - $subscriberCount = Subscriber::whereNotIn('email', $userEmails)->count(); + // Exclude ALL user emails from subscribers (users control their own sub via emailsub flag) + $userEmailSubquery = User::withoutGlobalScopes()->select('email'); + $subscriberCount = Subscriber::whereNotIn('email', $userEmailSubquery)->count(); $totalCount = $userCount + $subscriberCount; @@ -78,7 +78,7 @@ public function handle(): int }); // ─── Subscribers (deduplicated) ────────────────────────────── - Subscriber::whereNotIn('email', $userEmails) + Subscriber::whereNotIn('email', $userEmailSubquery) ->orderBy('id') ->chunk($chunkSize, function ($subscribers) use (&$dispatched, $bar) { foreach ($subscribers as $subscriber) { diff --git a/app/Exports/CreateCSVExport.php b/app/Exports/CreateCSVExport.php index 195b7d24d..1e3b370ae 100644 --- a/app/Exports/CreateCSVExport.php +++ b/app/Exports/CreateCSVExport.php @@ -2,7 +2,10 @@ namespace App\Exports; +use App\Enums\VerificationStatus; use App\Models\Litter\Tags\Category; +use App\Models\Litter\Tags\LitterObjectType; +use App\Models\Litter\Tags\Materials; use App\Models\Photo; use Illuminate\Bus\Queueable; @@ -29,6 +32,12 @@ class CreateCSVExport implements FromQuery, WithMapping, WithHeadings */ private array $categoryObjects = []; + /** @var array */ + private array $materials = []; + + /** @var array */ + private array $types = []; + public $timeout = 240; public function __construct($location_type, $location_id, $team_id = null, $user_id = null, array $dateFilter = []) @@ -51,6 +60,14 @@ public function __construct($location_type, $location_id, $team_id = null, $user ])->values()->toArray(), ]) ->toArray(); + + $this->materials = Materials::orderBy('id')->get() + ->map(fn ($m) => ['id' => $m->id, 'key' => $m->key]) + ->toArray(); + + $this->types = LitterObjectType::orderBy('id')->get() + ->map(fn ($t) => ['id' => $t->id, 'key' => $t->key]) + ->toArray(); } /** @@ -81,6 +98,21 @@ public function headings(): array } } + // Materials columns + $result[] = 'MATERIALS'; + foreach ($this->materials as $material) { + $result[] = $material['key']; + } + + // Types columns + $result[] = 'TYPES'; + foreach ($this->types as $type) { + $result[] = $type['key']; + } + + // Brands (single delimited column) + $result[] = 'brands'; + return array_merge($result, ['custom_tag_1', 'custom_tag_2', 'custom_tag_3']); } @@ -105,15 +137,31 @@ public function map($row): array ]; $tags = $row->summary['tags'] ?? []; + $brandKeys = $row->summary['keys']['brands'] ?? []; - // Build lookup: category_id → object_id → quantity (from flat tags array) + // Single pass: iterate nested summary structure + // Structure: { catId: { objId: { quantity, materials: {id: qty}, brands: {id: qty}, custom_tags } } } $tagLookup = []; - foreach ($tags as $tag) { - $catId = $tag['category_id'] ?? 0; - $objId = $tag['object_id'] ?? 0; - $tagLookup[$catId][$objId] = ($tagLookup[$catId][$objId] ?? 0) + ($tag['quantity'] ?? 0); + $materialLookup = []; + $brandParts = []; + + foreach ($tags as $catId => $objects) { + foreach ($objects as $objId => $tagData) { + $qty = $tagData['quantity'] ?? 0; + $tagLookup[$catId][$objId] = ($tagLookup[$catId][$objId] ?? 0) + $qty; + + foreach ($tagData['materials'] ?? [] as $materialId => $materialQty) { + $materialLookup[$materialId] = ($materialLookup[$materialId] ?? 0) + $materialQty; + } + + foreach ($tagData['brands'] ?? [] as $brandId => $brandQty) { + $brandName = $brandKeys[$brandId] ?? "brand_{$brandId}"; + $brandParts[$brandName] = ($brandParts[$brandName] ?? 0) + $brandQty; + } + } } + // Category/object columns foreach ($this->categoryObjects as $category) { $result[] = null; // category separator column @@ -122,6 +170,31 @@ public function map($row): array } } + // Materials columns + $result[] = null; // MATERIALS separator + foreach ($this->materials as $material) { + $result[] = $materialLookup[$material['id']] ?? null; + } + + // Types: read from DB relationship (NOT summary — type_id doesn't exist in summary) + $typeLookup = []; + foreach ($row->photoTags as $pt) { + if ($pt->litter_object_type_id) { + $typeLookup[$pt->litter_object_type_id] = + ($typeLookup[$pt->litter_object_type_id] ?? 0) + $pt->quantity; + } + } + + $result[] = null; // TYPES separator + foreach ($this->types as $type) { + $result[] = $typeLookup[$type['id']] ?? null; + } + + // Brands: single delimited column + $result[] = !empty($brandParts) + ? implode(';', array_map(fn ($name, $qty) => "{$name}:{$qty}", array_keys($brandParts), array_values($brandParts))) + : null; + // Custom tags from extra_tags (eager-loaded in query()) $customTagNames = $row->photoTags ->flatMap(fn ($pt) => $pt->extraTags->where('tag_type', 'custom_tag')) @@ -148,15 +221,20 @@ public function query() } if ($this->user_id) { - return $query->where(['user_id' => $this->user_id]); - } elseif ($this->team_id) { - return $query->where(['team_id' => $this->team_id, 'verified' => 2]); + return $query->where('user_id', $this->user_id); + } + + // Team/location exports: only approved photos (ADMIN_APPROVED and above) + $query->where('verified', '>=', VerificationStatus::ADMIN_APPROVED->value); + + if ($this->team_id) { + return $query->where('team_id', $this->team_id); } elseif ($this->location_type === 'city') { - return $query->where(['city_id' => $this->location_id, 'verified' => 2]); + return $query->where('city_id', $this->location_id); } elseif ($this->location_type === 'state') { - return $query->where(['state_id' => $this->location_id, 'verified' => 2]); + return $query->where('state_id', $this->location_id); } else { - return $query->where(['country_id' => $this->location_id, 'verified' => 2]); + return $query->where('country_id', $this->location_id); } } } diff --git a/app/Http/Controllers/API/TeamsController.php b/app/Http/Controllers/API/TeamsController.php index 685f6c6ad..da4b67632 100644 --- a/app/Http/Controllers/API/TeamsController.php +++ b/app/Http/Controllers/API/TeamsController.php @@ -4,6 +4,7 @@ use App\Actions\Teams\CreateTeamAction; use App\Actions\Teams\DownloadTeamDataAction; +use Carbon\Carbon; use App\Actions\Teams\JoinTeamAction; use App\Actions\Teams\LeaveTeamAction; use App\Actions\Teams\ListTeamMembersAction; @@ -232,7 +233,26 @@ public function download (Request $request, DownloadTeamDataAction $action): arr return $this->fail('not-a-member'); } - $action->run($user, $team); + if (!$team->isLeader($user->id) && !$user->hasRole('school_manager')) { + return $this->fail('not-authorized'); + } + + $dateFilter = []; + if ($request->dateField && ($request->fromDate || $request->toDate)) { + $dateFilter = [ + 'column' => in_array($request->dateField, ['created_at', 'datetime', 'updated_at']) + ? $request->dateField + : 'datetime', + 'fromDate' => $request->fromDate + ? Carbon::parse($request->fromDate)->toDateString() + : '2017-01-01', + 'toDate' => $request->toDate + ? Carbon::parse($request->toDate)->toDateString() + : now()->toDateString(), + ]; + } + + $action->run($user, $team, $dateFilter); return $this->success(); } diff --git a/app/Jobs/Emails/DispatchEmail.php b/app/Jobs/Emails/DispatchEmail.php index 2a38946e9..c4eab4e58 100644 --- a/app/Jobs/Emails/DispatchEmail.php +++ b/app/Jobs/Emails/DispatchEmail.php @@ -9,6 +9,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Mail; +use Illuminate\Queue\Middleware\RateLimited; class DispatchEmail implements ShouldQueue { @@ -25,6 +26,11 @@ public function __construct($user) $this->user = $user; } + public function middleware(): array + { + return [new RateLimited('ses-emails')]; + } + public function handle(): void { Mail::to($this->user->email)->send(new EmailUpdate($this->user)); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9d48f4803..7a90f8747 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,11 +9,12 @@ use App\Models\Users\User; use App\Observers\PhotoObserver; use App\Services\Clustering\ClusteringService; -use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; class AppServiceProvider extends ServiceProvider { @@ -47,5 +48,9 @@ public function boot() Gate::define('viewPulse', function (User $user) { return $user->hasRole('superadmin'); }); + + RateLimiter::for('ses-emails', function () { + return Limit::perSecond(12); + }); } } diff --git a/readme/API.md b/readme/API.md index 3d60719bb..975bdf27c 100644 --- a/readme/API.md +++ b/readme/API.md @@ -1990,13 +1990,32 @@ Returns max 5000 points with `id`, `lat`, `lng`, `tags`, `verified`, `is_public` ### POST /api/teams/download — Download Team Data -**Auth:** Required (Sanctum) +**Auth:** Required (Sanctum). Must be team leader or have `school_manager` role. + +**Request:** +```json +{ + "team_id": 1, + "dateField": "datetime", + "fromDate": "2025-01-01", + "toDate": "2025-12-31" +} +``` + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `team_id` | int | Yes | Team ID | +| `dateField` | string | No | Column to filter: `created_at`, `datetime`, or `updated_at` | +| `fromDate` | string | No | Start date (YYYY-MM-DD). Default: `2017-01-01` | +| `toDate` | string | No | End date (YYYY-MM-DD). Default: today | -**Request:** `{ "team_id": 1 }` **Response:** `{ "success": true }` -**Error:** `{ "success": false, "message": "not-a-member" }` +**Errors:** +- `{ "success": false, "message": "team-not-found" }` — invalid team_id +- `{ "success": false, "message": "not-a-member" }` — user not on team +- `{ "success": false, "message": "not-authorized" }` — member but not leader/school_manager -Queues background export job. +Queues background CSV export (only `verified >= ADMIN_APPROVED` photos). Emails S3 download link when ready. See `readme/ExportData.md` for full CSV column layout. --- diff --git a/readme/ExportData.md b/readme/ExportData.md new file mode 100644 index 000000000..58bc3c971 --- /dev/null +++ b/readme/ExportData.md @@ -0,0 +1,252 @@ +# Data Export System + +CSV export for users, teams, and locations. Queued via Maatwebsite/Excel to S3, then an email with a download link is sent to the user. + +## Architecture + +``` +User/Team/Location triggers export + ↓ +Controller validates auth + builds date filter + ↓ +CreateCSVExport queued to S3 (Maatwebsite/Excel FromQuery) + ↓ +EmailUserExportCompleted job chains → sends ExportWithLink mailable + ↓ +User receives email with S3 download URL +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `app/Exports/CreateCSVExport.php` | Core CSV export class (headings, mapping, query) | +| `app/Actions/Teams/DownloadTeamDataAction.php` | Orchestrates team export dispatch | +| `app/Http/Controllers/User/ProfileController.php` | User export endpoint (`download()`) | +| `app/Http/Controllers/API/TeamsController.php` | Team export endpoint (`download()`) | +| `app/Http/Controllers/DownloadControllerNew.php` | Location-based export endpoint | +| `app/Jobs/EmailUserExportCompleted.php` | Queued job — sends download email | +| `app/Mail/ExportWithLink.php` | Mailable with S3 URL | +| `config/excel.php` | Maatwebsite/Excel config (chunk size: 1000) | + +### Frontend + +| File | Purpose | +|------|---------| +| `resources/js/views/User/Uploads/components/UploadsHeader.vue` | Export CSV button on Uploads page | +| `resources/js/views/Teams/TeamsHub.vue` | Export CSV button on Teams page | +| `resources/js/stores/teams/index.js` | `downloadTeamData()` store action | + +### Tests + +| File | Coverage | +|------|----------| +| `tests/Unit/Exports/CreateCSVExportTest.php` | Headings, mapping, materials aggregation, brands format, types from DB, null values | +| `tests/Feature/Teams/DownloadTeamDataTest.php` | Member auth, non-member rejection, date filter | + +## API Endpoints + +### GET /api/user/profile/download — User Data Export + +**Auth:** Required (Sanctum) + +**Query params (all optional):** + +| Param | Type | Description | +|-------|------|-------------| +| `dateField` | string | Column to filter: `created_at`, `datetime`, or `updated_at` | +| `fromDate` | string | Start date (YYYY-MM-DD). Default: `2017-01-01` | +| `toDate` | string | End date (YYYY-MM-DD). Default: today | + +**Response:** `{ "success": true }` + +Exports **all** user photos (any verification status). Email sent when ready. + +### POST /api/teams/download — Team Data Export + +**Auth:** Required (Sanctum). Must be a team member. + +**Request body:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `team_id` | int | Yes | Team ID | +| `dateField` | string | No | Column to filter: `created_at`, `datetime`, or `updated_at` | +| `fromDate` | string | No | Start date (YYYY-MM-DD). Default: `2017-01-01` | +| `toDate` | string | No | End date (YYYY-MM-DD). Default: today | + +**Response:** `{ "success": true }` +**Error:** `{ "success": false, "message": "not-a-member" }` + +Only team leaders and `school_manager` role can export. Returns `{ "success": false, "message": "not-authorized" }` for other members. + +Exports only `verified >= ADMIN_APPROVED` team photos (includes BBOX_APPLIED, BBOX_VERIFIED, AI_READY). Email sent when ready. + +### POST /api/download — Location Data Export + +**Auth:** Optional (uses `email` param if unauthenticated) + +**Request body:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `locationType` | string | Yes | `country`, `state`, or `city` | +| `locationId` | int | Yes | Location ID | +| `email` | string | No | Required if unauthenticated | + +**Response:** `{ "success": true }` + +Exports only `verified >= ADMIN_APPROVED` photos for the location. + +## CSV Column Layout + +The CSV has four sections: + +### 1. Fixed columns (10) + +| Column | Source | +|--------|--------| +| `id` | `photos.id` | +| `verification` | `photos.verified` (VerificationStatus enum value) | +| `phone` | `photos.model` (device model) | +| `date_taken` | `photos.datetime` | +| `date_uploaded` | `photos.created_at` | +| `lat` | `photos.lat` | +| `lon` | `photos.lon` | +| `picked up` | Inverted from `photos.remaining` (`Yes`/`No`) | +| `address` | `photos.display_name` accessor (derived from `address_array` JSON) | +| `total_tags` | `summary.totals.litter` (fallback: `photos.total_tags`) | + +### 2. Category/object columns (~180 dynamic) + +One separator column per category (uppercase key, always null in data rows), then one column per litter object in that category. Value = aggregated quantity from `summary.tags.{catId}.{objId}.quantity`. + +``` +ALCOHOL, can, bottle, wrapper, ... +SMOKING, butts, lighters, ... +FOOD, sweetwrappers, ... +... +``` + +### 3. Materials, types, and brands columns (~76) + +| Section | Columns | Source | Format | +|---------|---------|--------|--------| +| Materials | `MATERIALS` separator + 40 material keys | `summary.tags.{catId}.{objId}.materials` (`{materialId: qty}`) | Integer quantity per material, aggregated across all tags | +| Types | `TYPES` separator + 33 type keys | `photo_tags.litter_object_type_id` (DB relationship) | Integer quantity per type, aggregated across all photo_tags | +| Brands | Single `brands` column | `summary.tags.{catId}.{objId}.brands` (`{brandId: qty}`) + `summary.keys.brands` for name resolution | Semicolon-delimited string: `brandname:qty;brandname:qty` | + +**Why types come from DB, not summary:** The `summary.tags` JSON does not include `type_id`. Types live on `photo_tags.litter_object_type_id` in the database. The `photoTags` relationship is eager-loaded in the export query. + +**Why brands are a single column:** There are 2,600+ brands in the database. One column per brand would make the CSV unusable. The delimited format is parseable with Excel `TEXTSPLIT()` or Python `str.split(';')`. + +### 4. Custom tags (3 fixed) + +| Column | Source | +|--------|--------| +| `custom_tag_1` | First custom tag key from `photo_tags.extraTags` where `tag_type = 'custom_tag'` | +| `custom_tag_2` | Second custom tag key | +| `custom_tag_3` | Third custom tag key | + +Extracted from the eager-loaded `photoTags.extraTags.extraTag` relationship. Limited to 3 columns for backward compatibility. + +## Summary JSON Structure + +The `map()` method reads from the nested `photos.summary` JSON: + +```json +{ + "tags": { + "2": { + "15": { + "quantity": 5, + "materials": { "3": 5, "7": 5 }, + "brands": { "12": 3 }, + "custom_tags": {} + } + } + }, + "totals": { + "litter": 15, + "materials": 10, + "brands": 3, + "custom_tags": 0 + }, + "keys": { + "categories": { "2": "smoking" }, + "objects": { "15": "butts" }, + "materials": { "3": "plastic", "7": "paper" }, + "brands": { "12": "marlboro" } + } +} +``` + +- **Tags**: Nested `{ categoryId: { objectId: { quantity, materials, brands, custom_tags } } }` +- **Materials**: `{ materialId: quantity }` objects (NOT arrays of IDs) +- **Brands**: `{ brandId: quantity }` objects with independent quantities +- **Custom tags**: `{ customTagId: quantity }` objects +- **Keys**: Human-readable name lookups by ID (used for brand name resolution in CSV) + +## Date Filter Plumbing + +Both user and team exports support the same date filter contract: + +```php +$dateFilter = [ + 'column' => 'datetime', // or 'created_at' or 'updated_at' + 'fromDate' => '2025-01-01', // YYYY-MM-DD + 'toDate' => '2025-12-31', // YYYY-MM-DD +]; +``` + +- **User exports** (`ProfileController::download()`): Parses from query params. Defaults: `fromDate = 2017-01-01`, `toDate = now()`. +- **Team exports** (`TeamsController::download()`): Parses from request body. Same defaults. Whitelists `dateField` to `created_at`, `datetime`, `updated_at`. +- **`CreateCSVExport`**: Applies via `whereBetween($column, [$fromDate, $toDate])` in `query()`. + +## S3 Path Patterns + +| Scope | Path | +|-------|------| +| User (no filter) | `YYYY/MM/DD/UNIX_MyData_OpenLitterMap.csv` | +| User (date filter) | `YYYY/MM/DD/UNIX_from_DATE_to_DATE_MyData_OpenLitterMap.csv` | +| Team (no filter) | `YYYY/MM/DD/UNIX/_Team_OpenLitterMap.csv` | +| Team (date filter) | `YYYY/MM/DD/UNIX_from_DATE_to_DATE/_Team_OpenLitterMap.csv` | +| Location | `YYYY/MM/DD/UNIX/{LocationName}_OpenLitterMap.csv` | + +## Frontend Integration + +### Uploads Page (`UploadsHeader.vue`) + +- "Export CSV" button next to the "Apply" filter button +- Sends `GET /api/user/profile/download` with `dateField: 'datetime'` and the current `dateFrom`/`dateTo` filter values +- Shows inline success message: "Export started — check your email for the download link." + +### Teams Page (`TeamsHub.vue`) + +- "Export CSV" button next to the period selector in the team header +- Converts the period selector value (`today`, `week`, `month`, `year`, `all`) to `fromDate`/`toDate` date strings +- Sends `POST /api/teams/download` with `team_id` and date filter +- Uses toast notification for success/error feedback +- Only visible/functional for team leaders and school managers (server returns `not-authorized` for other members) + +### Teams Store (`stores/teams/index.js`) + +```js +async downloadTeamData(teamId, dateFilter = {}) { + await axios.post('/api/teams/download', { team_id: teamId, ...dateFilter }); +} +``` + +## Query Scoping Rules + +| Export Type | Verification Filter | Auth | Photos Included | +|-------------|-------------------|------|-----------------| +| User | None | Any authenticated user (own data) | All photos (any status) | +| Team | `verified >= ADMIN_APPROVED` | Team leader or `school_manager` only | Approved photos (ADMIN_APPROVED, BBOX_APPLIED, BBOX_VERIFIED, AI_READY) | +| Location | `verified >= ADMIN_APPROVED` | Optional (email param for guests) | Approved photos (same as above) | + +School team photos with `is_public = false` are excluded because teacher approval is required to reach `ADMIN_APPROVED`, and approval also sets `is_public = true`. + +## Timeout + +Export job timeout: 240 seconds. The `FromQuery` concern chunks automatically (1000 rows per chunk via `config/excel.php`). diff --git a/resources/js/stores/teams/index.js b/resources/js/stores/teams/index.js index ba9cba5dd..6cc51ffd5 100644 --- a/resources/js/stores/teams/index.js +++ b/resources/js/stores/teams/index.js @@ -290,11 +290,15 @@ export const useTeamsStore = defineStore('teams', { } }, - async downloadTeamData(teamId) { + async downloadTeamData(teamId, dateFilter = {}) { try { - await axios.post('/api/teams/download', { team_id: teamId }); + await axios.post('/api/teams/download', { + team_id: teamId, + ...dateFilter, + }); } catch (e) { console.error('downloadTeamData', e); + throw e; } }, diff --git a/resources/js/views/Teams/TeamsHub.vue b/resources/js/views/Teams/TeamsHub.vue index 8dcd89059..007224b91 100644 --- a/resources/js/views/Teams/TeamsHub.vue +++ b/resources/js/views/Teams/TeamsHub.vue @@ -274,17 +274,28 @@

- +
+ + + +
@@ -392,6 +403,44 @@ const isSchoolManager = computed(() => { return roles.some((r) => r.name === 'school_manager'); }); const joinErrors = computed(() => teamsStore.errors); +const exporting = ref(false); + +const getPeriodDateFilter = () => { + const now = new Date(); + const toDate = now.toISOString().split('T')[0]; + let fromDate = null; + + if (period.value === 'today') { + fromDate = toDate; + } else if (period.value === 'week') { + const d = new Date(now); + d.setDate(d.getDate() - 7); + fromDate = d.toISOString().split('T')[0]; + } else if (period.value === 'month') { + const d = new Date(now); + d.setMonth(d.getMonth() - 1); + fromDate = d.toISOString().split('T')[0]; + } else if (period.value === 'year') { + const d = new Date(now); + d.setFullYear(d.getFullYear() - 1); + fromDate = d.toISOString().split('T')[0]; + } + + if (!fromDate) return {}; + return { dateField: 'datetime', fromDate, toDate }; +}; + +const exportTeamCsv = async () => { + exporting.value = true; + try { + await teamsStore.downloadTeamData(selectedTeamId.value, getPeriodDateFilter()); + toast.success('Export started — check your email for the download link.'); + } catch { + toast.error('Export failed. Please try again.'); + } finally { + exporting.value = false; + } +}; // Show "try OLM yourself first" banner for school managers who haven't uploaded yet const showFacilitatorOnboarding = computed(() => { diff --git a/resources/js/views/User/Uploads/components/UploadsHeader.vue b/resources/js/views/User/Uploads/components/UploadsHeader.vue index 4f9e2fc31..83ee624ba 100644 --- a/resources/js/views/User/Uploads/components/UploadsHeader.vue +++ b/resources/js/views/User/Uploads/components/UploadsHeader.vue @@ -1,9 +1,33 @@ diff --git a/tests/Feature/Teams/DownloadTeamDataTest.php b/tests/Feature/Teams/DownloadTeamDataTest.php index ebda24c44..8adcfeeb6 100644 --- a/tests/Feature/Teams/DownloadTeamDataTest.php +++ b/tests/Feature/Teams/DownloadTeamDataTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Spatie\Permission\Models\Role; +use Spatie\Permission\PermissionRegistrar; use Tests\TestCase; class DownloadTeamDataTest extends TestCase @@ -96,4 +98,27 @@ public function test_a_leader_can_download_with_date_filter() return true; }); } + + public function test_a_school_manager_can_download_a_teams_data() + { + Mail::fake(); + Storage::fake('s3'); + Carbon::setTestNow(now()); + + Role::firstOrCreate(['name' => 'school_manager', 'guard_name' => 'web']); + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + + /** @var User $manager */ + $manager = User::factory()->create(); + $manager->assignRole('school_manager'); + /** @var Team $team */ + $team = Team::factory()->create(); + $manager->teams()->attach($team); + + $response = $this->actingAs($manager)->postJson("api/teams/download?team_id=$team->id"); + + $response->assertOk(); + $response->assertJson(['success' => true]); + Mail::assertSent(ExportWithLink::class); + } } diff --git a/tests/Unit/Exports/CreateCSVExportTest.php b/tests/Unit/Exports/CreateCSVExportTest.php index 5bc44772d..64dddd312 100644 --- a/tests/Unit/Exports/CreateCSVExportTest.php +++ b/tests/Unit/Exports/CreateCSVExportTest.php @@ -12,6 +12,7 @@ use App\Models\Litter\Tags\PhotoTag; use App\Models\Litter\Tags\PhotoTagExtraTags; use App\Models\Photo; +use App\Models\Users\User; use Database\Seeders\Tags\GenerateTagsSeeder; use Tests\TestCase; @@ -23,65 +24,93 @@ protected function setUp(): void $this->seed(GenerateTagsSeeder::class); } - public function test_it_has_correct_headings_for_all_categories_and_tags() + public function test_empty_export_has_only_fixed_columns() { $expected = ['id', 'verification', 'phone', 'date_taken', 'date_uploaded', 'lat', 'lon', 'picked up', 'address', 'total_tags']; - $categories = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')])->orderBy('id')->get(); - foreach ($categories as $category) { - $expected[] = strtoupper($category->key); - foreach ($category->litterObjects as $object) { - $expected[] = $object->key; - } - } + $export = new CreateCSVExport('null', 1, null, null); - // Materials columns - $expected[] = 'MATERIALS'; - foreach (Materials::orderBy('id')->get() as $material) { - $expected[] = $material->key; - } + $this->assertEquals($expected, $export->headings()); + } - // Types columns - $expected[] = 'TYPES'; - foreach (LitterObjectType::orderBy('id')->get() as $type) { - $expected[] = $type->key; - } + public function test_headings_include_only_columns_with_data() + { + $category = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')]) + ->orderBy('id') + ->get() + ->first(fn ($c) => $c->litterObjects->count() >= 2); + $obj1 = $category->litterObjects[0]; + $obj2 = $category->litterObjects[1]; - // Brands + custom tags - $expected[] = 'brands'; - $expected = array_merge($expected, ['custom_tag_1', 'custom_tag_2', 'custom_tag_3']); + $cloId1 = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj1->id)->value('id'); - $this->assertDatabaseCount('photos', 0); + $material = Materials::orderBy('id')->first(); + $type = LitterObjectType::orderBy('id')->first(); + $brand = BrandList::firstOrCreate(['key' => 'test_brand']); + $customTag = CustomTagNew::firstOrCreate(['key' => 'my_custom']); - $export = new CreateCSVExport('null', 1, null, null); + // Create a user photo with specific tags + $user = User::factory()->create(); + $photo = Photo::factory()->create(['verified' => 2, 'user_id' => $user->id]); + $pt = PhotoTag::create([ + 'photo_id' => $photo->id, + 'category_id' => $category->id, + 'litter_object_id' => $obj1->id, + 'category_litter_object_id' => $cloId1, + 'litter_object_type_id' => $type->id, + 'quantity' => 3, + ]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt->id, 'tag_type' => 'material', 'tag_type_id' => $material->id, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt->id, 'tag_type' => 'brand', 'tag_type_id' => $brand->id, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag->id, 'quantity' => 1]); - $this->assertEquals($expected, $export->headings()); - $this->assertDatabaseCount('photos', 0); + $export = new CreateCSVExport(null, null, null, $user->id); + $headings = $export->headings(); + + // Fixed columns always present + $this->assertEquals('id', $headings[0]); + $this->assertEquals('total_tags', $headings[9]); + + // Only the used category + its used object should appear (not all categories) + $this->assertContains(strtoupper($category->key), $headings); + $this->assertContains($obj1->key, $headings); + // obj2 is in the same category but has no photo_tags — should be excluded + $this->assertNotContains($obj2->key, $headings); + + // Only the used material, type, brand, custom_tag sections should appear + $this->assertContains('MATERIALS', $headings); + $this->assertContains($material->key, $headings); + $this->assertContains('TYPES', $headings); + $this->assertContains($type->key, $headings); + $this->assertContains('brands', $headings); + $this->assertContains('custom_tag_1', $headings); + + // Unused materials/types should NOT appear + $otherMaterial = Materials::where('id', '!=', $material->id)->orderBy('id')->first(); + if ($otherMaterial) { + $this->assertNotContains($otherMaterial->key, $headings); + } } - public function test_it_has_correct_mappings_for_all_categories_and_tags() + public function test_it_has_correct_mappings() { - // Pick a category with at least 2 objects (unclassified only has 1) $category = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')]) ->orderBy('id') ->get() ->first(fn ($c) => $c->litterObjects->count() >= 2); - $objects = $category->litterObjects; - $obj1 = $objects[0]; - $obj2 = $objects[1]; + $obj1 = $category->litterObjects[0]; + $obj2 = $category->litterObjects[1]; $cloId1 = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj1->id)->value('id'); $cloId2 = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj2->id)->value('id'); - // Get a material for testing (seeded by GenerateTagsSeeder) $material = Materials::orderBy('id')->first(); - - // Create a brand (not seeded) $brand = BrandList::firstOrCreate(['key' => 'test_brand_export']); - // Nested summary format: { catId: { objId: { quantity, materials: {id: qty}, brands: {id: qty} } } } + $user = User::factory()->create(); $photo = Photo::factory()->create([ 'verified' => 2, + 'user_id' => $user->id, 'model' => 'Redmi Note 8 pro', 'datetime' => now()->toDateTimeString(), 'lat' => 42.0, @@ -91,151 +120,100 @@ public function test_it_has_correct_mappings_for_all_categories_and_tags() 'total_tags' => 15, 'summary' => [ 'tags' => [ - (string) $category->id => [ - (string) $obj1->id => [ - 'quantity' => 5, - 'materials' => [(string) $material->id => 5], - 'brands' => [(string) $brand->id => 2], - 'custom_tags' => (object) [], - ], - (string) $obj2->id => [ - 'quantity' => 10, - 'materials' => (object) [], - 'brands' => (object) [], - 'custom_tags' => (object) [], - ], - ], + ['clo_id' => $cloId1, 'category_id' => $category->id, 'object_id' => $obj1->id, 'type_id' => null, 'quantity' => 5, 'materials' => [$material->id], 'brands' => [$brand->id => 2], 'custom_tags' => []], + ['clo_id' => $cloId2, 'category_id' => $category->id, 'object_id' => $obj2->id, 'type_id' => null, 'quantity' => 10, 'materials' => [], 'brands' => (object) [], 'custom_tags' => []], ], 'totals' => ['litter' => 15, 'materials' => 5, 'brands' => 2, 'custom_tags' => 0], - 'keys' => [ - 'brands' => [(string) $brand->id => 'test_brand_export'], - ], + 'keys' => ['brands' => [(string) $brand->id => 'test_brand_export']], ], ]); - // Create custom tags via v5 photo_tags + // Create photo_tags so the pre-scan finds columns + $pt1 = PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj1->id, 'category_litter_object_id' => $cloId1, 'quantity' => 5]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt1->id, 'tag_type' => 'material', 'tag_type_id' => $material->id, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt1->id, 'tag_type' => 'brand', 'tag_type_id' => $brand->id, 'quantity' => 2]); + PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj2->id, 'category_litter_object_id' => $cloId2, 'quantity' => 10]); + + // Add custom tags $customTag1 = CustomTagNew::firstOrCreate(['key' => 'my_custom_1']); $customTag2 = CustomTagNew::firstOrCreate(['key' => 'my_custom_2']); $customTag3 = CustomTagNew::firstOrCreate(['key' => 'my_custom_3']); $unclassifiedCloId = $this->getUnclassifiedOtherCloId(); - $pt1 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); - PhotoTagExtraTags::create(['photo_tag_id' => $pt1->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag1->id, 'quantity' => 1]); - $pt2 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); - PhotoTagExtraTags::create(['photo_tag_id' => $pt2->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag2->id, 'quantity' => 1]); - $pt3 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); - PhotoTagExtraTags::create(['photo_tag_id' => $pt3->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag3->id, 'quantity' => 1]); - - $this->assertDatabaseCount('photos', 1); - - $export = new CreateCSVExport('null', 1, null, null); + $ptc1 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $ptc1->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag1->id, 'quantity' => 1]); + $ptc2 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $ptc2->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag2->id, 'quantity' => 1]); + $ptc3 = PhotoTag::create(['photo_id' => $photo->id, 'category_litter_object_id' => $unclassifiedCloId, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $ptc3->id, 'tag_type' => 'custom_tag', 'tag_type_id' => $customTag3->id, 'quantity' => 1]); + + $export = new CreateCSVExport(null, null, null, $user->id); + $mapped = $export->map($photo->fresh()); + $headings = $export->headings(); - // Build expected row - $expected = [ - $photo->id, - $photo->verified, - 'Redmi Note 8 pro', - $photo->datetime, - $photo->created_at, - 42.0, - 42.0, - 'No', // remaining=true means not picked up - $photo->display_name, - 15, // total_objects from summary - ]; - - // Category/object columns — only the two tagged objects have values - $allCategories = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')])->orderBy('id')->get(); - foreach ($allCategories as $cat) { - $expected[] = null; // category separator - foreach ($cat->litterObjects as $obj) { - if ($cat->id === $category->id && $obj->id === $obj1->id) { - $expected[] = 5; - } elseif ($cat->id === $category->id && $obj->id === $obj2->id) { - $expected[] = 10; - } else { - $expected[] = null; - } - } - } + // Fixed columns + $this->assertEquals($photo->id, $mapped[0]); + $this->assertEquals(2, $mapped[1]); // verified->value + $this->assertEquals('No', $mapped[7]); // picked_up = false (remaining=true) + $this->assertEquals(15, $mapped[9]); // total_tags - // Materials columns — material has qty 5 (from the nested materials object) - $expected[] = null; // MATERIALS separator - foreach (Materials::orderBy('id')->get() as $mat) { - $expected[] = $mat->id === $material->id ? 5 : null; - } + // Object quantities in correct columns + $obj1Index = array_search($obj1->key, $headings); + $obj2Index = array_search($obj2->key, $headings); + $this->assertEquals(5, $mapped[$obj1Index]); + $this->assertEquals(10, $mapped[$obj2Index]); - // Types columns — no photo_tags with litter_object_type_id in this test - $expected[] = null; // TYPES separator - foreach (LitterObjectType::orderBy('id')->get() as $type) { - $expected[] = null; - } - - // Brands column - $expected[] = 'test_brand_export:2'; + // Material in correct column + $matIndex = array_search($material->key, $headings); + $this->assertEquals(5, $mapped[$matIndex]); // inherits parent tag qty - $expected = array_merge($expected, ['my_custom_1', 'my_custom_2', 'my_custom_3']); + // Brands + $brandsIndex = array_search('brands', $headings); + $this->assertEquals('test_brand_export:2', $mapped[$brandsIndex]); - $this->assertEquals($expected, $export->map($photo->fresh())); - $this->assertDatabaseCount('photos', 1); + // Custom tags + $ct1Index = array_search('custom_tag_1', $headings); + $this->assertEquals('my_custom_1', $mapped[$ct1Index]); + $this->assertEquals('my_custom_2', $mapped[$ct1Index + 1]); + $this->assertEquals('my_custom_3', $mapped[$ct1Index + 2]); } - public function test_it_maps_to_null_values_for_all_missing_categories() + public function test_it_maps_to_null_values_for_empty_tags() { + $category = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')]) + ->orderBy('id') + ->get() + ->first(fn ($c) => $c->litterObjects->count() >= 1); + $obj = $category->litterObjects->first(); + $cloId = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj->id)->value('id'); + + // Photo with empty summary but has a photo_tag (so category appears in pre-scan) + $user = User::factory()->create(); $photo = Photo::factory()->create([ 'verified' => 2, - 'model' => 'Redmi Note 8 pro', + 'user_id' => $user->id, + 'model' => 'Test', 'datetime' => now()->toDateTimeString(), 'lat' => 42.0, 'lon' => 42.0, 'remaining' => true, - 'address_array' => ['road' => '12345 Street', 'country' => 'Ireland'], - 'summary' => [ - 'tags' => (object) [], - 'totals' => ['litter' => 0, 'materials' => 0, 'brands' => 0, 'custom_tags' => 0], - ], + 'address_array' => ['country' => 'Ireland'], + 'summary' => ['tags' => [], 'totals' => ['litter' => 0, 'materials' => 0, 'brands' => 0, 'custom_tags' => 0]], ]); + PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj->id, 'category_litter_object_id' => $cloId, 'quantity' => 0]); - $expected = [ - $photo->id, - $photo->verified, - 'Redmi Note 8 pro', - $photo->datetime, - $photo->created_at, - 42.0, - 42.0, - 'No', - $photo->display_name, - 0, // total_objects from summary - ]; - - $allCategories = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')])->orderBy('id')->get(); - foreach ($allCategories as $cat) { - $expected[] = null; // category separator - foreach ($cat->litterObjects as $obj) { - $expected[] = null; - } - } - - // Materials — all null - $expected[] = null; - foreach (Materials::orderBy('id')->get() as $mat) { - $expected[] = null; - } - - // Types — all null - $expected[] = null; - foreach (LitterObjectType::orderBy('id')->get() as $type) { - $expected[] = null; - } - - // Brands — null - $expected[] = null; - - $expected = array_merge($expected, [null, null, null]); + $export = new CreateCSVExport(null, null, null, $user->id); + $mapped = $export->map($photo->fresh()); + $headings = $export->headings(); - $export = new CreateCSVExport('null', 1, null, null); + // Object column should be null (summary has no tags) + $objIndex = array_search($obj->key, $headings); + $this->assertNull($mapped[$objIndex]); - $this->assertEquals($expected, $export->map($photo->fresh())); + // No materials/types/brands/custom_tags sections + $this->assertNotContains('MATERIALS', $headings); + $this->assertNotContains('TYPES', $headings); + $this->assertNotContains('brands', $headings); + $this->assertNotContains('custom_tag_1', $headings); } public function test_materials_are_aggregated_across_multiple_tags() @@ -244,46 +222,44 @@ public function test_materials_are_aggregated_across_multiple_tags() ->orderBy('id') ->get() ->first(fn ($c) => $c->litterObjects->count() >= 2); - $objects = $category->litterObjects; - $obj1 = $objects[0]; - $obj2 = $objects[1]; + $obj1 = $category->litterObjects[0]; + $obj2 = $category->litterObjects[1]; + + $cloId1 = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj1->id)->value('id'); + $cloId2 = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj2->id)->value('id'); $material = Materials::orderBy('id')->first(); - // Nested summary: both objects have the same material with different quantities + $user = User::factory()->create(); $photo = Photo::factory()->create([ 'verified' => 2, + 'user_id' => $user->id, 'datetime' => now()->toDateTimeString(), 'lat' => 42.0, 'lon' => 42.0, 'address_array' => ['country' => 'Ireland'], 'summary' => [ 'tags' => [ - (string) $category->id => [ - (string) $obj1->id => [ - 'quantity' => 3, - 'materials' => [(string) $material->id => 3], - 'brands' => (object) [], - 'custom_tags' => (object) [], - ], - (string) $obj2->id => [ - 'quantity' => 7, - 'materials' => [(string) $material->id => 7], - 'brands' => (object) [], - 'custom_tags' => (object) [], - ], - ], + ['clo_id' => $cloId1, 'category_id' => $category->id, 'object_id' => $obj1->id, 'type_id' => null, 'quantity' => 3, 'materials' => [$material->id], 'brands' => (object) [], 'custom_tags' => []], + ['clo_id' => $cloId2, 'category_id' => $category->id, 'object_id' => $obj2->id, 'type_id' => null, 'quantity' => 7, 'materials' => [$material->id], 'brands' => (object) [], 'custom_tags' => []], ], 'totals' => ['litter' => 10, 'materials' => 10, 'brands' => 0, 'custom_tags' => 0], ], ]); - $export = new CreateCSVExport('null', 1, null, null); + // Photo tags for pre-scan + $pt1 = PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj1->id, 'category_litter_object_id' => $cloId1, 'quantity' => 3]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt1->id, 'tag_type' => 'material', 'tag_type_id' => $material->id, 'quantity' => 1]); + $pt2 = PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj2->id, 'category_litter_object_id' => $cloId2, 'quantity' => 7]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt2->id, 'tag_type' => 'material', 'tag_type_id' => $material->id, 'quantity' => 1]); + + $export = new CreateCSVExport(null, null, null, $user->id); $mapped = $export->map($photo->fresh()); $headings = $export->headings(); - // Find the material column — search after the MATERIALS separator $materialsHeaderIndex = array_search('MATERIALS', $headings); + $this->assertNotFalse($materialsHeaderIndex); + $materialIndex = null; for ($i = $materialsHeaderIndex + 1; $i < count($headings); $i++) { if ($headings[$i] === $material->key) { @@ -292,9 +268,8 @@ public function test_materials_are_aggregated_across_multiple_tags() } } - $this->assertNotNull($materialIndex, "Material column '{$material->key}' not found in headings"); - // Material should be 3 + 7 = 10 (sum of both tag quantities) - $this->assertEquals(10, $mapped[$materialIndex]); + $this->assertNotNull($materialIndex); + $this->assertEquals(10, $mapped[$materialIndex]); // 3 + 7 } public function test_brands_formatted_as_delimited_string() @@ -304,49 +279,46 @@ public function test_brands_formatted_as_delimited_string() ->get() ->first(fn ($c) => $c->litterObjects->count() >= 1); $obj = $category->litterObjects->first(); + $cloId = CategoryObject::where('category_id', $category->id)->where('litter_object_id', $obj->id)->value('id'); $brand1 = BrandList::firstOrCreate(['key' => 'test_brand_1']); $brand2 = BrandList::firstOrCreate(['key' => 'test_brand_2']); - // Nested summary with brands as {id: qty} objects + $user = User::factory()->create(); $photo = Photo::factory()->create([ 'verified' => 2, + 'user_id' => $user->id, 'datetime' => now()->toDateTimeString(), 'lat' => 42.0, 'lon' => 42.0, 'address_array' => ['country' => 'Ireland'], 'summary' => [ 'tags' => [ - (string) $category->id => [ - (string) $obj->id => [ - 'quantity' => 5, - 'materials' => (object) [], - 'brands' => [(string) $brand1->id => 1, (string) $brand2->id => 3], - 'custom_tags' => (object) [], - ], - ], + ['clo_id' => $cloId, 'category_id' => $category->id, 'object_id' => $obj->id, 'type_id' => null, 'quantity' => 5, 'materials' => [], 'brands' => [(string) $brand1->id => 1, (string) $brand2->id => 3], 'custom_tags' => []], ], 'totals' => ['litter' => 5, 'materials' => 0, 'brands' => 4, 'custom_tags' => 0], - 'keys' => [ - 'brands' => [(string) $brand1->id => 'test_brand_1', (string) $brand2->id => 'test_brand_2'], - ], + 'keys' => ['brands' => [(string) $brand1->id => 'test_brand_1', (string) $brand2->id => 'test_brand_2']], ], ]); - $export = new CreateCSVExport('null', 1, null, null); + $pt = PhotoTag::create(['photo_id' => $photo->id, 'category_id' => $category->id, 'litter_object_id' => $obj->id, 'category_litter_object_id' => $cloId, 'quantity' => 5]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt->id, 'tag_type' => 'brand', 'tag_type_id' => $brand1->id, 'quantity' => 1]); + PhotoTagExtraTags::create(['photo_tag_id' => $pt->id, 'tag_type' => 'brand', 'tag_type_id' => $brand2->id, 'quantity' => 3]); + + $export = new CreateCSVExport(null, null, null, $user->id); $mapped = $export->map($photo->fresh()); $headings = $export->headings(); $brandsIndex = array_search('brands', $headings); - $brandsValue = $mapped[$brandsIndex]; + $this->assertNotFalse($brandsIndex); - $this->assertNotNull($brandsValue); + $brandsValue = $mapped[$brandsIndex]; $this->assertStringContainsString('test_brand_1:1', $brandsValue); $this->assertStringContainsString('test_brand_2:3', $brandsValue); $this->assertStringContainsString(';', $brandsValue); } - public function test_types_are_mapped_from_photo_tags() + public function test_types_are_mapped_from_summary() { $category = Category::with(['litterObjects' => fn ($q) => $q->orderBy('litter_objects.id')]) ->orderBy('id') @@ -357,29 +329,22 @@ public function test_types_are_mapped_from_photo_tags() $type = LitterObjectType::orderBy('id')->first(); - // Summary doesn't contain type_id — types come from photo_tags DB rows + $user = User::factory()->create(); $photo = Photo::factory()->create([ 'verified' => 2, + 'user_id' => $user->id, 'datetime' => now()->toDateTimeString(), 'lat' => 42.0, 'lon' => 42.0, 'address_array' => ['country' => 'Ireland'], 'summary' => [ 'tags' => [ - (string) $category->id => [ - (string) $obj->id => [ - 'quantity' => 8, - 'materials' => (object) [], - 'brands' => (object) [], - 'custom_tags' => (object) [], - ], - ], + ['clo_id' => $cloId, 'category_id' => $category->id, 'object_id' => $obj->id, 'type_id' => $type->id, 'quantity' => 8, 'materials' => [], 'brands' => (object) [], 'custom_tags' => []], ], 'totals' => ['litter' => 8, 'materials' => 0, 'brands' => 0, 'custom_tags' => 0], ], ]); - // Create a photo_tag row with litter_object_type_id — this is the DB source for types PhotoTag::create([ 'photo_id' => $photo->id, 'category_id' => $category->id, @@ -389,12 +354,13 @@ public function test_types_are_mapped_from_photo_tags() 'quantity' => 8, ]); - $export = new CreateCSVExport('null', 1, null, null); + $export = new CreateCSVExport(null, null, null, $user->id); $mapped = $export->map($photo->fresh()); $headings = $export->headings(); - // Find the type column — search after TYPES separator $typesHeaderIndex = array_search('TYPES', $headings); + $this->assertNotFalse($typesHeaderIndex); + $typeIndex = null; for ($i = $typesHeaderIndex + 1; $i < count($headings); $i++) { if ($headings[$i] === $type->key) { @@ -403,20 +369,7 @@ public function test_types_are_mapped_from_photo_tags() } } - $this->assertNotNull($typeIndex, "Type column '{$type->key}' not found in headings"); + $this->assertNotNull($typeIndex); $this->assertEquals(8, $mapped[$typeIndex]); - - // Other types should be null - $otherType = LitterObjectType::where('id', '!=', $type->id)->orderBy('id')->first(); - if ($otherType) { - $otherIndex = null; - for ($i = $typesHeaderIndex + 1; $i < count($headings); $i++) { - if ($headings[$i] === $otherType->key) { - $otherIndex = $i; - break; - } - } - $this->assertNull($mapped[$otherIndex]); - } } } From 768b05f7877cadee0c70e493a7e9f86da4a1423d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=C3=A1n=20L=2E?= Date: Mon, 6 Apr 2026 17:52:07 +0100 Subject: [PATCH 3/3] team ui updates --- .../Teams/TeamPhotosController.php | 42 ++++++++++++---- package.json | 2 +- readme/changelog/2026-04-05.md | 3 ++ readme/changelog/2026-04-06.md | 6 +++ resources/js/composables/usePhotoUrl.js | 12 +++++ .../Tagging/v2/components/PhotoViewer.vue | 17 ++++--- resources/js/views/Teams/CreateTeam.vue | 26 +++++----- resources/js/views/Teams/FacilitatorQueue.vue | 6 +-- resources/js/views/Teams/ParticipantEntry.vue | 4 +- .../js/views/Teams/ParticipantWorkspace.vue | 21 ++++---- .../js/views/Teams/TeamApprovalQueue.vue | 11 +++-- resources/js/views/Teams/TeamOverview.vue | 24 ++++----- resources/js/views/Teams/TeamPhotoEdit.vue | 5 +- resources/js/views/Teams/TeamPhotoList.vue | 13 ++--- resources/js/views/Teams/TeamPhotoMap.vue | 49 ++++++++++++++++--- resources/js/views/Teams/TeamSettingsTab.vue | 20 ++++---- resources/js/views/Teams/TeamsHub.vue | 24 ++++----- resources/js/views/Teams/TeamsLeaderboard.vue | 28 +++++------ .../components/FacilitatorQueueFilters.vue | 8 +-- .../components/FacilitatorQueueHeader.vue | 6 +-- .../Teams/components/ParticipantGrid.vue | 12 ++--- .../Teams/components/TeamMembersList.vue | 6 +-- .../js/views/User/Uploads/PhotoPreview.vue | 12 +---- resources/js/views/User/Uploads/Uploads.vue | 3 +- 24 files changed, 218 insertions(+), 142 deletions(-) create mode 100644 readme/changelog/2026-04-06.md create mode 100644 resources/js/composables/usePhotoUrl.js diff --git a/app/Http/Controllers/Teams/TeamPhotosController.php b/app/Http/Controllers/Teams/TeamPhotosController.php index 0e512e167..2a5501934 100644 --- a/app/Http/Controllers/Teams/TeamPhotosController.php +++ b/app/Http/Controllers/Teams/TeamPhotosController.php @@ -446,19 +446,41 @@ public function mapPoints(Request $request): JsonResponse $points = Photo::where('team_id', $team->id) ->whereNotNull('lat') ->whereNotNull('lon') - ->select(['id', 'lat', 'lon', 'verified', 'is_public', 'total_tags', 'created_at']) + ->with(['user:id,name,username,show_username_maps,show_name_maps,global_flag']) + ->select([ + 'id', 'user_id', 'lat', 'lon', 'verified', 'is_public', + 'total_tags', 'remaining', 'filename', 'datetime', 'summary', + 'created_at', + ]) ->orderByDesc('created_at') ->limit(5000) ->get() - ->map(fn ($photo) => [ - 'id' => $photo->id, - 'lat' => $photo->lat, - 'lng' => $photo->lon, - 'tags' => $photo->total_tags, - 'verified' => $photo->verified->value, - 'is_public' => $photo->is_public, - 'date' => $photo->created_at->toDateString(), - ]); + ->map(function ($photo) use ($team) { + $applySafeguarding = $team->safeguarding; + + return [ + 'id' => $photo->id, + 'lat' => $photo->lat, + 'lng' => $photo->lon, + 'tags' => $photo->total_tags, + 'verified' => $photo->verified->value, + 'is_public' => $photo->is_public, + 'date' => $photo->created_at->toDateString(), + // Popup fields — same shape as global map PointsController::show + 'filename' => $photo->filename, + 'datetime' => $photo->datetime, + 'picked_up' => $photo->picked_up, + 'summary' => $photo->summary, + 'team' => $team->name, + 'name' => $applySafeguarding ? null : ( + $photo->user && $photo->user->show_name_maps ? $photo->user->name : null + ), + 'username' => $applySafeguarding ? null : ( + $photo->user && $photo->user->show_username_maps ? $photo->user->username : null + ), + 'flag' => $applySafeguarding ? null : $photo->user?->global_flag, + ]; + }); return response()->json([ 'success' => true, diff --git a/package.json b/package.json index 5d1954b21..0f371bed5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openlittermap-web", - "version": "5.8.0", + "version": "5.8.2", "type": "module", "author": "Seán Lynch", "license": "GPL v3", diff --git a/readme/changelog/2026-04-05.md b/readme/changelog/2026-04-05.md index 10c0ce9e7..9a445d917 100644 --- a/readme/changelog/2026-04-05.md +++ b/readme/changelog/2026-04-05.md @@ -1,5 +1,8 @@ # 2026-04-05 +## v5.8.1 +- fix(export): hide empty CSV columns via pre-scan, subquery-based pre-scan (no `pluck` memory bloat), single extra-tags query, DRY `scopeQuery()`, SQL injection whitelist, `picked_up` accessor, leader/school_manager auth test, filtered stats on Uploads page, error message color fix + ## v5.8.0 - feat(export): CSV data export for users and teams — full v5 tag data (materials, types, brands), date filters, export buttons on Uploads + Teams pages, leader/school_manager auth, `verified >= ADMIN_APPROVED`, filtered stats, new `readme/ExportData.md` diff --git a/readme/changelog/2026-04-06.md b/readme/changelog/2026-04-06.md new file mode 100644 index 000000000..063daeb87 --- /dev/null +++ b/readme/changelog/2026-04-06.md @@ -0,0 +1,6 @@ +# 2026-04-06 + +## v5.8.2 +- fix(photos): add shared `resolvePhotoUrl()` composable for S3/MinIO URL handling — fixes broken images in TeamPhotoList, TeamPhotoEdit, TeamApprovalQueue, ParticipantWorkspace; refactors Uploads.vue, PhotoPreview.vue, and PhotoViewer.vue to use same helper (covers FacilitatorQueue, AdminQueue, AddTags automatically) +- feat(teams): team map popup now matches global map — shows photo image, tag summary, user attribution, picked-up status, and date via `popupHelper`; images load directly via `resolvePhotoUrl` (bypasses signed-url endpoint for pending private photos) +- fix(teams): improve text readability across 14 team Vue components — bump `text-white/30`→`/50`, `text-white/40`→`/60`, `text-white/50`→`/60` (section headers), `placeholder-white/30`→`/50`, `text-slate-400`→`slate-500` (light theme), `text-gray-400`→`gray-300` (facilitator queue); skip disabled button states diff --git a/resources/js/composables/usePhotoUrl.js b/resources/js/composables/usePhotoUrl.js new file mode 100644 index 000000000..e8ba15947 --- /dev/null +++ b/resources/js/composables/usePhotoUrl.js @@ -0,0 +1,12 @@ +/** + * Resolve a photo filename to a displayable URL. + * + * In production, filename is a full S3 URL (https://olm-s3.s3...). + * In local dev with MinIO, filename is a full MinIO URL (http://localhost:9000/...). + * If filename is a relative path, prepend the current origin. + */ +export function resolvePhotoUrl(filename) { + if (!filename) return '/assets/images/waiting.png'; + if (filename.startsWith('http') || filename.startsWith('//')) return filename; + return `${window.location.origin}${filename}`; +} diff --git a/resources/js/views/General/Tagging/v2/components/PhotoViewer.vue b/resources/js/views/General/Tagging/v2/components/PhotoViewer.vue index e811fd527..a4b754d86 100644 --- a/resources/js/views/General/Tagging/v2/components/PhotoViewer.vue +++ b/resources/js/views/General/Tagging/v2/components/PhotoViewer.vue @@ -3,7 +3,7 @@