1919use App \Models \Foundation \Main \IGroup ;
2020use App \Models \Foundation \Main \Strategies \MemberSummitStrategyFactory ;
2121use App \Models \Foundation \Summit \Events \RSVP \RSVPInvitation ;
22+ use Doctrine \DBAL \Exception ;
2223use Doctrine \ORM \Query \ResultSetMappingBuilder ;
2324use Illuminate \Support \Facades \Config ;
2425use 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
34733487UPDATE Sponsor_Users
34743488SET Permissions = IF(
@@ -3478,45 +3492,65 @@ public function addSponsorPermission(int $sponsor_id, string $group_slug): void
34783492)
34793493WHERE SponsorID = :sponsor_id AND MemberID = :member_id
34803494SQL ;
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
34983524UPDATE 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+ )
35003536WHERE SponsorID = :sponsor_id AND MemberID = :member_id
35013537AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35023538SQL ;
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
35113546SELECT COUNT(*) FROM Sponsor_Users
35123547WHERE MemberID = :member_id
35133548AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35143549SQL ;
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
0 commit comments