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 {
@@ -1902,8 +1902,9 @@ public function getSponsorMembershipIds(Summit $summit): array
19021902 public function hasSponsorMembershipsFor (Summit $ summit , Sponsor $ sponsor = null ): bool
19031903 {
19041904 try {
1905- $ canHaveSponsorMemberships = $ this ->isSponsorUser () || $ this ->isExternalSponsorUser ();
1906- if (!$ canHaveSponsorMemberships ) return false ;
1905+ $ canHaveSponsorMemberships = $ this ->isSponsorUser () || $ this ->isExternalSponsorUser ();
1906+ if (!$ canHaveSponsorMemberships ) return false ;
1907+
19071908 $ sql = <<<SQL
19081909SELECT COUNT(Sponsor_Users.SponsorID)
19091910FROM Sponsor_Users
@@ -3466,9 +3467,23 @@ public function getIndividualMemberJoinDate(): ?\DateTime
34663467 * Appends $group_slug to the Permissions JSON array on the Sponsor_Users row
34673468 * for this member and the given sponsor. Idempotent: the slug is only added
34683469 * when it is not already present.
3470+ *
3471+ * An exclusive row lock (SELECT … FOR UPDATE) is acquired first so that
3472+ * concurrent jobs for the same (member, sponsor, slug) serialize here and
3473+ * the second job always reads the post-first-job value, preventing duplicates.
3474+ *
3475+ * Returns the number of rows matched by the WHERE clause (0 when the
3476+ * Sponsor_Users row does not yet exist, 1 when it does).
34693477 */
3470- public function addSponsorPermission (int $ sponsor_id , string $ group_slug ): void
3478+ public function addSponsorPermission (int $ sponsor_id , string $ group_slug ): int
34713479 {
3480+ // Lock the row before the read-modify-write so concurrent transactions
3481+ // serialize and the IF(JSON_CONTAINS) in the UPDATE sees the committed state.
3482+ $ this ->prepareRawSQL (
3483+ 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE ' ,
3484+ ['sponsor_id ' => $ sponsor_id , 'member_id ' => $ this ->getId ()]
3485+ )->executeQuery ();
3486+
34723487 $ sql = <<<SQL
34733488UPDATE Sponsor_Users
34743489SET Permissions = IF(
@@ -3478,45 +3493,65 @@ public function addSponsorPermission(int $sponsor_id, string $group_slug): void
34783493)
34793494WHERE SponsorID = :sponsor_id AND MemberID = :member_id
34803495SQL ;
3481- $ stmt = $ this ->prepareRawSQL ($ sql , [
3496+ return $ this ->prepareRawSQL ($ sql , [
34823497 'group_slug ' => $ group_slug ,
34833498 'sponsor_id ' => $ sponsor_id ,
34843499 'member_id ' => $ this ->getId (),
3485- ]);
3486- $ stmt ->executeStatement ();
3500+ ])->executeStatement ();
34873501 }
34883502
34893503 /**
34903504 * Removes $group_slug from the Permissions JSON array on the Sponsor_Users row
34913505 * for this member and the given sponsor, then returns how many other Sponsor_Users
34923506 * rows for this member still carry that slug. The caller uses the count to decide
34933507 * whether to also revoke the global group membership.
3508+ *
3509+ * An exclusive row lock is acquired first so the remove UPDATE and the
3510+ * remaining-count SELECT are not interleaved with concurrent operations.
3511+ * All occurrences of the slug are removed (via JSON_ARRAYAGG filter) to
3512+ * prevent stale entries if a prior race introduced duplicates.
34943513 */
34953514 public function removeSponsorPermission (int $ sponsor_id , string $ group_slug ): int
34963515 {
3516+ // Serialize concurrent removals for the same row.
3517+ $ this ->prepareRawSQL (
3518+ 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE ' ,
3519+ ['sponsor_id ' => $ sponsor_id , 'member_id ' => $ this ->getId ()]
3520+ )->executeQuery ();
3521+
3522+ // Remove ALL occurrences (not just the first) so duplicate slugs
3523+ // introduced by any prior race cannot leave stale entries behind.
34973524 $ removeSQL = <<<SQL
34983525UPDATE Sponsor_Users
3499- SET Permissions = JSON_REMOVE(Permissions, JSON_UNQUOTE(JSON_SEARCH(Permissions, 'one', :group_slug)))
3526+ SET Permissions = COALESCE(
3527+ (
3528+ SELECT JSON_ARRAYAGG(element)
3529+ FROM JSON_TABLE(
3530+ COALESCE(Permissions, '[]'), '$[*]'
3531+ COLUMNS(element VARCHAR(255) PATH '$')
3532+ ) AS jt
3533+ WHERE element != :group_slug
3534+ ),
3535+ '[]'
3536+ )
35003537WHERE SponsorID = :sponsor_id AND MemberID = :member_id
35013538AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35023539SQL ;
3503- $ stmt = $ this ->prepareRawSQL ($ removeSQL , [
3540+ $ this ->prepareRawSQL ($ removeSQL , [
35043541 'group_slug ' => $ group_slug ,
35053542 'sponsor_id ' => $ sponsor_id ,
35063543 'member_id ' => $ this ->getId (),
3507- ]);
3508- $ stmt ->executeStatement ();
3544+ ])->executeStatement ();
35093545
35103546 $ countSQL = <<<SQL
35113547SELECT COUNT(*) FROM Sponsor_Users
35123548WHERE MemberID = :member_id
35133549AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug))
35143550SQL ;
3515- $ stmt = $ this ->prepareRawSQL ($ countSQL , [
3551+ return intval ( $ this ->prepareRawSQL ($ countSQL , [
35163552 'member_id ' => $ this ->getId (),
35173553 'group_slug ' => $ group_slug ,
3518- ]);
3519- return intval ($ stmt ->executeQuery ()->fetchOne ());
3554+ ])->executeQuery ()->fetchOne ());
35203555 }
35213556
35223557 public function addSponsorMembership (Sponsor $ sponsor ):void
0 commit comments