Skip to content

Commit 075363a

Browse files
committed
finalize matchmaking remediation for media mapping and profile UX
Add automated avatar/logo syncing for matchmaking profiles, apply the volunteer introduction remap requested by review, refine topics UI copy, and document QA status while awaiting final spreadsheet harmonization import. Made-with: Cursor
1 parent b3932e5 commit 075363a

67 files changed

Lines changed: 440 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\MatchmakingProfile;
6+
use App\Services\Matchmaking\ProfileAvatarResolver;
7+
use Illuminate\Console\Command;
8+
9+
class MatchmakingSyncAvatars extends Command
10+
{
11+
protected $signature = 'matchmaking:sync-avatars {--dry-run : Preview changes without saving}';
12+
13+
protected $description = 'Sync matchmaking profile avatars from public image folders by matching filenames to profile names/slugs';
14+
15+
public function handle(ProfileAvatarResolver $resolver): int
16+
{
17+
$dryRun = (bool) $this->option('dry-run');
18+
$profiles = MatchmakingProfile::query()->get();
19+
20+
$updates = 0;
21+
$skipped = 0;
22+
$missing = 0;
23+
24+
foreach ($profiles as $profile) {
25+
$resolved = $resolver->resolveForProfile($profile);
26+
if (empty($resolved)) {
27+
$missing++;
28+
continue;
29+
}
30+
31+
if ($profile->avatar === $resolved) {
32+
$skipped++;
33+
continue;
34+
}
35+
36+
$updates++;
37+
$displayName = $profile->organisation_name ?: trim(($profile->first_name ?? '') . ' ' . ($profile->last_name ?? ''));
38+
39+
$this->line(sprintf(
40+
'[%s] %s (%s) -> %s',
41+
$dryRun ? 'DRY' : 'SET',
42+
$displayName ?: ('Profile #' . $profile->id),
43+
$profile->slug,
44+
$resolved
45+
));
46+
47+
if (!$dryRun) {
48+
$profile->avatar = $resolved;
49+
$profile->save();
50+
}
51+
}
52+
53+
$this->newLine();
54+
$this->info("Profiles scanned: {$profiles->count()}");
55+
$this->info("Updated: {$updates}");
56+
$this->info("Already matched: {$skipped}");
57+
$this->info("No matching image found: {$missing}");
58+
59+
return self::SUCCESS;
60+
}
61+
}
62+

app/Imports/MatchmakingProfileImport.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\MatchmakingProfile;
66
use App\Country;
7+
use App\Services\Matchmaking\ProfileAvatarResolver;
78
use Carbon\Carbon;
89
use Illuminate\Database\Eloquent\Model;
910
use Illuminate\Support\Facades\Log;
@@ -16,6 +17,13 @@
1617

1718
class MatchmakingProfileImport extends DefaultValueBinder implements ToModel, WithCustomValueBinder, WithHeadingRow
1819
{
20+
private ProfileAvatarResolver $avatarResolver;
21+
22+
public function __construct()
23+
{
24+
$this->avatarResolver = app(ProfileAvatarResolver::class);
25+
}
26+
1927
/**
2028
* Parse semicolon or comma separated values into array
2129
*/
@@ -890,6 +898,17 @@ public function model(array $row): ?Model
890898
'completion_time' => $completionTime,
891899
];
892900

901+
$resolvedAvatar = $this->avatarResolver->resolve(
902+
$type,
903+
$organisationName,
904+
$firstName,
905+
$lastName,
906+
$slug
907+
);
908+
if (!empty($resolvedAvatar)) {
909+
$profileData['avatar'] = $resolvedAvatar;
910+
}
911+
893912
if ($existingProfile && empty($email)) {
894913
unset($profileData['email']);
895914
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<?php
2+
3+
namespace App\Services\Matchmaking;
4+
5+
use App\MatchmakingProfile;
6+
use Illuminate\Support\Str;
7+
8+
class ProfileAvatarResolver
9+
{
10+
private const ORG_DIR = 'images/matchmaking-tool/matchmaking-logos';
11+
private const VOLUNTEER_DIR = 'images/matchmaking-tool/matchmaking-volunteers';
12+
13+
private const ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'svg'];
14+
15+
/**
16+
* @var array<string, string>|null
17+
*/
18+
private ?array $organisationIndex = null;
19+
20+
/**
21+
* @var array<string, string>|null
22+
*/
23+
private ?array $volunteerIndex = null;
24+
25+
public function resolveForProfile(MatchmakingProfile $profile): ?string
26+
{
27+
return $this->resolve(
28+
$profile->type,
29+
$profile->organisation_name,
30+
$profile->first_name,
31+
$profile->last_name,
32+
$profile->slug
33+
);
34+
}
35+
36+
public function resolve(
37+
?string $type,
38+
?string $organisationName,
39+
?string $firstName,
40+
?string $lastName,
41+
?string $slug = null
42+
): ?string {
43+
$isOrganisation = $type === MatchmakingProfile::TYPE_ORGANISATION;
44+
$index = $isOrganisation ? $this->getOrganisationIndex() : $this->getVolunteerIndex();
45+
46+
if (empty($index)) {
47+
return null;
48+
}
49+
50+
$candidates = [];
51+
if (!empty($slug)) {
52+
$candidates[] = $slug;
53+
}
54+
if (!empty($organisationName)) {
55+
$candidates[] = $organisationName;
56+
}
57+
58+
$fullName = trim(implode(' ', array_filter([(string) $firstName, (string) $lastName])));
59+
if ($fullName !== '') {
60+
$candidates[] = $fullName;
61+
}
62+
63+
foreach ($candidates as $candidate) {
64+
$normalized = $this->normalize($candidate);
65+
if (isset($index[$normalized])) {
66+
return '/' . ltrim($index[$normalized], '/');
67+
}
68+
}
69+
70+
return null;
71+
}
72+
73+
/**
74+
* @return array<string, string>
75+
*/
76+
public function getOrganisationIndex(): array
77+
{
78+
if ($this->organisationIndex === null) {
79+
$this->organisationIndex = $this->buildIndex(self::ORG_DIR);
80+
}
81+
82+
return $this->organisationIndex;
83+
}
84+
85+
/**
86+
* @return array<string, string>
87+
*/
88+
public function getVolunteerIndex(): array
89+
{
90+
if ($this->volunteerIndex === null) {
91+
$this->volunteerIndex = $this->buildIndex(self::VOLUNTEER_DIR);
92+
}
93+
94+
return $this->volunteerIndex;
95+
}
96+
97+
/**
98+
* @return array<string, string>
99+
*/
100+
private function buildIndex(string $relativeDirectory): array
101+
{
102+
$absoluteDirectory = public_path($relativeDirectory);
103+
if (!is_dir($absoluteDirectory)) {
104+
return [];
105+
}
106+
107+
$index = [];
108+
$files = scandir($absoluteDirectory) ?: [];
109+
110+
foreach ($files as $file) {
111+
if ($file === '.' || $file === '..') {
112+
continue;
113+
}
114+
115+
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
116+
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
117+
continue;
118+
}
119+
120+
$basename = pathinfo($file, PATHINFO_FILENAME);
121+
$normalized = $this->normalize($basename);
122+
if ($normalized === '') {
123+
continue;
124+
}
125+
126+
$index[$normalized] = trim($relativeDirectory . '/' . $file, '/');
127+
}
128+
129+
return $index;
130+
}
131+
132+
private function normalize(string $value): string
133+
{
134+
return Str::slug(trim($value));
135+
}
136+
}
137+

docs/matchmaking-remediation-qa.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Matchmaking Remediation QA Report
2+
3+
Date: 2026-04-20
4+
5+
## Scope Checked
6+
7+
- Pagination/filter behavior fixes
8+
- Topic taxonomy alignment
9+
- Profile introduction remap for individuals
10+
- Avatar/logo matching sync process
11+
- Bulk importer support for auto-avatar mapping
12+
13+
## Verification Results
14+
15+
- Pagination + filter request wiring: implemented in frontend component and previously deployed.
16+
- Canonical topic list: implemented and returns expected 11 options.
17+
- Individual profile section mapping: implemented.
18+
- `Introduction` now uses `why_volunteering` (fallback: `description`).
19+
- Dedicated `Why am I volunteering?` accordion section removed.
20+
- Avatar sync command:
21+
- Command available: `php artisan matchmaking:sync-avatars`
22+
- Latest dry-run summary:
23+
- Updated: 0
24+
- Already matched: 13
25+
- No matching image found: 6
26+
- Current local (`codeweek.europa`) data snapshot:
27+
- `profiles_total=19`
28+
- `profiles_with_avatar=15`
29+
30+
## Bulk Importer Updates
31+
32+
- Import flow now includes automatic avatar resolution by filename matching.
33+
- New resolver service:
34+
- `app/Services/Matchmaking/ProfileAvatarResolver.php`
35+
- Importer uses resolver:
36+
- `app/Imports/MatchmakingProfileImport.php`
37+
- Manual backfill command:
38+
- `php artisan matchmaking:sync-avatars --dry-run`
39+
- `php artisan matchmaking:sync-avatars`
40+
41+
## Outstanding / Blocked
42+
43+
- Full spreadsheet harmonization import pass is blocked pending the final spreadsheet file path/input.
44+
- Once provided, run the Nova importer (or CLI import flow) and then rerun:
45+
- `php artisan matchmaking:sync-avatars`
46+
- QA checks above
47+
48+
## Recommended Finalization Sequence
49+
50+
1. Import final harmonized spreadsheet.
51+
2. Run avatar sync command.
52+
3. Spot-check key profiles (including Sara Buonporto and selected organisations).
53+
4. Validate filter combinations + pagination in UI.
54+
5. Capture final screenshot evidence for reviewer closure.
55+
59.5 KB
Loading
3.28 KB
Loading
9.89 KB
Loading
9.24 KB
Loading
3.62 KB
Loading
27 KB
Loading

0 commit comments

Comments
 (0)