Skip to content

Commit 4a1e844

Browse files
committed
fix: review feedback
Signed-off-by: romanetar <roman_ag@hotmail.com>
1 parent 4391757 commit 4a1e844

7 files changed

Lines changed: 153 additions & 24 deletions

File tree

app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ protected function addChild(Summit $summit, array $payload): IEntity
126126
if (is_null($current_member))
127127
throw new HTTP403ForbiddenException();
128128

129+
$isSponsorUser = $current_member->isSponsorUser() || $current_member->isExternalSponsorUser();
130+
131+
if (!$isSponsorUser) {
132+
throw new HTTP403ForbiddenException('Only sponsor users can add badge scans.');
133+
}
134+
129135
return $this->service->addBadgeScan($summit, $current_member, $payload);
130136
}
131137

app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,19 @@ public function handle(SponsorServicesMQJob $job): void
6060
Log::debug("UpdateSponsorMemberGroupsMQJob::handle payload {$json}");
6161

6262
$data = $payload['data'];
63+
if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) {
64+
throw new ValidationException('Invalid payload: user_external_id, group_slug, sponsor_id and summit_id are required.');
65+
}
66+
6367
$user_external_id = intval($data['user_external_id']);
6468
$group_slug = $data['group_slug'];
6569
$sponsor_id = intval($data['sponsor_id']);
6670
$summit_id = intval($data['summit_id']);
6771

72+
if ($user_external_id <= 0 || $sponsor_id <= 0 || $summit_id <= 0 || trim((string)$group_slug) === '') {
73+
throw new ValidationException('Invalid payload: identifiers must be positive and group_slug must be non-empty.');
74+
}
75+
6876
if ($event_type === EventTypes::AUTH_USER_ADDED_TO_GROUP) {
6977
$this->service->addSponsorUserToGroup($user_external_id, $group_slug, $sponsor_id, $summit_id);
7078
} else if ($event_type === EventTypes::AUTH_USER_REMOVED_FROM_GROUP) {
@@ -85,4 +93,4 @@ public function failed(array $data, Throwable $exception): void
8593
{
8694
Log::error("UpdateSponsorMemberGroupsMQJob::failed {$exception->getMessage()}");
8795
}
88-
}
96+
}

app/Models/Foundation/Main/Member.php

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Models\Foundation\Main\IGroup;
2020
use App\Models\Foundation\Main\Strategies\MemberSummitStrategyFactory;
2121
use App\Models\Foundation\Summit\Events\RSVP\RSVPInvitation;
22+
use Doctrine\DBAL\Exception;
2223
use Doctrine\ORM\Query\ResultSetMappingBuilder;
2324
use Illuminate\Support\Facades\Config;
2425
use Doctrine\DBAL\ParameterType;
@@ -1860,21 +1861,20 @@ public function getActiveSummitsSponsorMemberships()
18601861
return [];
18611862
}
18621863

1863-
// Step 2 — load each Sponsor by PK. find() uses a different code path that avoids
1864-
// the ORM 3 assertion failure triggered by the OneToOne inverse associations on Sponsor
1865-
// (lead_report_setting, sponsorservices_statistics) when using DQL/native query hydration.
1866-
$sponsors = [];
1867-
foreach ($ids as $id) {
1868-
$sponsor = $this->getEM()->find(\models\summit\Sponsor::class, $id);
1869-
if ($sponsor !== null) {
1870-
$sponsors[] = $sponsor;
1871-
}
1872-
}
1864+
// Step 2 — load all sponsors in a single IN query. findBy() uses PK-based hydration
1865+
// which avoids the ORM 3 assertion failure triggered by the OneToOne inverse associations
1866+
// on Sponsor (lead_report_setting, sponsorservices_statistics) that DQL/native-query
1867+
// hydration hits. The result set is then re-sorted to match the SQL ORDER BY.
1868+
$position = array_flip($ids);
1869+
$sponsors = EntityManager::getRepository(Sponsor::class)->findBy(['id' => $ids]);
1870+
usort($sponsors, fn($a, $b) => $position[$a->getId()] <=> $position[$b->getId()]);
18731871
return $sponsors;
18741872
}
18751873

18761874
/**
1875+
* @param Summit $summit
18771876
* @return array
1877+
* @throws Exception
18781878
*/
18791879
public function getSponsorMembershipIds(Summit $summit): array
18801880
{
@@ -3466,9 +3466,23 @@ public function getIndividualMemberJoinDate(): ?\DateTime
34663466
* Appends $group_slug to the Permissions JSON array on the Sponsor_Users row
34673467
* for this member and the given sponsor. Idempotent: the slug is only added
34683468
* when it is not already present.
3469+
*
3470+
* An exclusive row lock (SELECT … FOR UPDATE) is acquired first so that
3471+
* concurrent jobs for the same (member, sponsor, slug) serialize here and
3472+
* the second job always reads the post-first-job value, preventing duplicates.
3473+
*
3474+
* Returns the number of rows matched by the WHERE clause (0 when the
3475+
* Sponsor_Users row does not yet exist, 1 when it does).
34693476
*/
3470-
public function addSponsorPermission(int $sponsor_id, string $group_slug): void
3477+
public function addSponsorPermission(int $sponsor_id, string $group_slug): int
34713478
{
3479+
// Lock the row before the read-modify-write so concurrent transactions
3480+
// serialize and the IF(JSON_CONTAINS) in the UPDATE sees the committed state.
3481+
$this->prepareRawSQL(
3482+
'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE',
3483+
['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()]
3484+
)->executeQuery();
3485+
34723486
$sql = <<<SQL
34733487
UPDATE Sponsor_Users
34743488
SET Permissions = IF(
@@ -3478,45 +3492,65 @@ public function addSponsorPermission(int $sponsor_id, string $group_slug): void
34783492
)
34793493
WHERE SponsorID = :sponsor_id AND MemberID = :member_id
34803494
SQL;
3481-
$stmt = $this->prepareRawSQL($sql, [
3495+
return $this->prepareRawSQL($sql, [
34823496
'group_slug' => $group_slug,
34833497
'sponsor_id' => $sponsor_id,
34843498
'member_id' => $this->getId(),
3485-
]);
3486-
$stmt->executeStatement();
3499+
])->executeStatement();
34873500
}
34883501

34893502
/**
34903503
* Removes $group_slug from the Permissions JSON array on the Sponsor_Users row
34913504
* for this member and the given sponsor, then returns how many other Sponsor_Users
34923505
* rows for this member still carry that slug. The caller uses the count to decide
34933506
* whether to also revoke the global group membership.
3507+
*
3508+
* An exclusive row lock is acquired first so the remove UPDATE and the
3509+
* remaining-count SELECT are not interleaved with concurrent operations.
3510+
* All occurrences of the slug are removed (via JSON_ARRAYAGG filter) to
3511+
* prevent stale entries if a prior race introduced duplicates.
34943512
*/
34953513
public function removeSponsorPermission(int $sponsor_id, string $group_slug): int
34963514
{
3515+
// Serialize concurrent removals for the same row.
3516+
$this->prepareRawSQL(
3517+
'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE',
3518+
['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()]
3519+
)->executeQuery();
3520+
3521+
// Remove ALL occurrences (not just the first) so duplicate slugs
3522+
// introduced by any prior race cannot leave stale entries behind.
34973523
$removeSQL = <<<SQL
34983524
UPDATE Sponsor_Users
3499-
SET Permissions = JSON_REMOVE(Permissions, JSON_UNQUOTE(JSON_SEARCH(Permissions, 'one', :group_slug)))
3525+
SET Permissions = COALESCE(
3526+
(
3527+
SELECT JSON_ARRAYAGG(element)
3528+
FROM JSON_TABLE(
3529+
COALESCE(Permissions, '[]'), '$[*]'
3530+
COLUMNS(element VARCHAR(255) PATH '$')
3531+
) AS jt
3532+
WHERE element != :group_slug
3533+
),
3534+
'[]'
3535+
)
35003536
WHERE SponsorID = :sponsor_id AND MemberID = :member_id
35013537
AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35023538
SQL;
3503-
$stmt = $this->prepareRawSQL($removeSQL, [
3539+
$this->prepareRawSQL($removeSQL, [
35043540
'group_slug' => $group_slug,
35053541
'sponsor_id' => $sponsor_id,
35063542
'member_id' => $this->getId(),
3507-
]);
3508-
$stmt->executeStatement();
3543+
])->executeStatement();
35093544

35103545
$countSQL = <<<SQL
35113546
SELECT COUNT(*) FROM Sponsor_Users
35123547
WHERE MemberID = :member_id
35133548
AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35143549
SQL;
3515-
$stmt = $this->prepareRawSQL($countSQL, [
3550+
return intval($this->prepareRawSQL($countSQL, [
35163551
'member_id' => $this->getId(),
35173552
'group_slug' => $group_slug,
3518-
]);
3519-
return intval($stmt->executeQuery()->fetchOne());
3553+
])->executeQuery()->fetchOne());
35203554
}
35213555

35223556
public function addSponsorMembership(Sponsor $sponsor):void

app/Services/Model/Imp/SponsorUserInfoGrantService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data
193193
throw new ValidationException("Current member does not belong to the selected summit sponsor.");
194194
}
195195

196+
if(!$current_member->hasSponsorMembershipsFor($summit, $sponsor))
197+
throw new ValidationException("Current member does not have badge scan permissions for the selected sponsor.");
198+
196199
$scan = new SponsorBadgeScan();
197200
$scan->setScanDate($scan_date);
198201
$scan->setQRCode($qr_code);

app/Services/Model/Imp/SponsorUserSyncService.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,23 @@ public function addSponsorUserToGroup(int $user_id, string $group_slug, int $spo
154154
}
155155

156156
// Add permission entry to the Sponsor_Users JSON column for this sponsor-member pair.
157-
$member->addSponsorPermission($sponsor_id, $group_slug);
157+
// If the row does not exist yet (MQ ordering race: group event arrived before membership
158+
// event), create it eagerly so the permission is never silently dropped.
159+
if ($member->addSponsorPermission($sponsor_id, $group_slug) === 0) {
160+
Log::warning(
161+
"SponsorUserSyncService::addSponsorUserToGroup no Sponsor_Users row found for " .
162+
"member {$member->getId()} / sponsor {$sponsor_id} — creating it eagerly");
163+
164+
$summit = $this->summit_repository->getById($summit_id);
165+
if (!$summit instanceof Summit) {
166+
throw new EntityNotFoundException("Summit {$summit_id} not found");
167+
}
168+
169+
$this->summit_sponsor_service->addSponsorUser($summit, $sponsor_id, $member->getId());
170+
171+
// Retry now that the row exists.
172+
$member->addSponsorPermission($sponsor_id, $group_slug);
173+
}
158174

159175
// Add to global group only if not already a member.
160176
if (!$member->belongsToGroup($group_slug)) {

app/Services/Model/Imp/SummitSponsorService.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,9 @@ public function addSponsorUser(Summit $summit, int $sponsor_id, int $member_id):
394394
// due a member could be on 2 diff places at same time ...
395395
// (StartA <= EndB) and (EndA >= StartB)
396396

397-
if ($current_summit_begin_date <= $former_summit_end_date && $current_summit_end_date >= $former_summit_begin_date) {
397+
if ($summit->getId() != $former_summit->getId() &&
398+
$current_summit_begin_date <= $former_summit_end_date &&
399+
$current_summit_end_date >= $former_summit_begin_date) {
398400
throw new ValidationException
399401
(
400402
sprintf
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php namespace Database\Migrations\Model;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Doctrine\DBAL\Schema\Schema;
16+
use Doctrine\Migrations\AbstractMigration;
17+
18+
/**
19+
* Class Version20260408120000
20+
* @package Database\Migrations\Model
21+
*
22+
* Backfills the Permissions JSON column on Sponsor_Users with the group codes
23+
* (Group.Code) of every group the member belongs to, derived from Group_Members.
24+
* Only rows where Permissions IS NULL are updated (i.e. rows that pre-date the
25+
* per-sponsor permission tracking feature added in Version20260402153110).
26+
*/
27+
final class Version20260408102410 extends AbstractMigration
28+
{
29+
public function getDescription(): string
30+
{
31+
return 'Backfill Sponsor_Users.Permissions JSON from existing Group_Members rows.';
32+
}
33+
34+
public function up(Schema $schema): void
35+
{
36+
// For each Sponsor_Users row whose Permissions have not been set yet,
37+
// aggregate all Group.Code values for the member and store them as a
38+
// JSON array. JSON_ARRAYAGG returns NULL when there are no matching
39+
// groups, which is a valid (empty-permissions) state.
40+
$this->addSql(<<<SQL
41+
UPDATE Sponsor_Users su
42+
SET su.Permissions = (
43+
SELECT JSON_ARRAYAGG(g.Code)
44+
FROM Group_Members gm
45+
INNER JOIN `Group` g ON g.ID = gm.GroupID
46+
WHERE gm.MemberID = su.MemberID
47+
)
48+
WHERE su.Permissions IS NULL
49+
SQL
50+
);
51+
}
52+
53+
public function down(Schema $schema): void
54+
{
55+
// Revert the backfill by clearing all Permissions values. This is the
56+
// only safe inverse: we cannot distinguish backfilled values from those
57+
// written by the application after the migration ran.
58+
$this->addSql("UPDATE Sponsor_Users SET Permissions = NULL");
59+
}
60+
}

0 commit comments

Comments
 (0)