-
Notifications
You must be signed in to change notification settings - Fork 2
feat: per-sponsor member permission tracking with JSON column on Sponsor_User #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7e11d50
788e00f
4391757
4cda54a
04376d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,13 +60,23 @@ public function handle(SponsorServicesMQJob $job): void | |
| Log::debug("UpdateSponsorMemberGroupsMQJob::handle payload {$json}"); | ||
|
|
||
| $data = $payload['data']; | ||
| if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) { | ||
| throw new ValidationException('Invalid payload: user_external_id, group_slug, sponsor_id and summit_id are required.'); | ||
| } | ||
|
Comment on lines
62
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add check for Line 62 accesses Proposed fix- $data = $payload['data'];
- if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) {
+ if (!isset($payload['data'])) {
+ throw new ValidationException('Invalid payload: data is required.');
+ }
+ $data = $payload['data'];
+ if (!isset($data['user_external_id'], $data['group_slug'], $data['sponsor_id'], $data['summit_id'])) {🤖 Prompt for AI Agents |
||
|
|
||
| $user_external_id = intval($data['user_external_id']); | ||
| $group_slug = $data['group_slug']; | ||
| $sponsor_id = intval($data['sponsor_id']); | ||
romanetar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| $summit_id = intval($data['summit_id']); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if ($user_external_id <= 0 || $sponsor_id <= 0 || $summit_id <= 0 || trim((string)$group_slug) === '') { | ||
| throw new ValidationException('Invalid payload: identifiers must be positive and group_slug must be non-empty.'); | ||
| } | ||
|
|
||
| if ($event_type === EventTypes::AUTH_USER_ADDED_TO_GROUP) { | ||
| $this->service->addSponsorUserToGroup($user_external_id, $group_slug); | ||
| $this->service->addSponsorUserToGroup($user_external_id, $group_slug, $sponsor_id, $summit_id); | ||
| } else if ($event_type === EventTypes::AUTH_USER_REMOVED_FROM_GROUP) { | ||
| $this->service->removeSponsorUserFromGroup($user_external_id, $group_slug); | ||
| $this->service->removeSponsorUserFromGroup($user_external_id, $group_slug, $sponsor_id, $summit_id); | ||
| } | ||
| $job->delete(); | ||
| } catch (\Exception $ex) { | ||
|
|
@@ -83,4 +93,4 @@ public function failed(array $data, Throwable $exception): void | |
| { | ||
| Log::error("UpdateSponsorMemberGroupsMQJob::failed {$exception->getMessage()}"); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,8 +19,10 @@ | |
| use App\Models\Foundation\Main\IGroup; | ||
| use App\Models\Foundation\Main\Strategies\MemberSummitStrategyFactory; | ||
| use App\Models\Foundation\Summit\Events\RSVP\RSVPInvitation; | ||
| use Doctrine\DBAL\Exception; | ||
| use Doctrine\ORM\Query\ResultSetMappingBuilder; | ||
| use Illuminate\Support\Facades\Config; | ||
| use Doctrine\DBAL\ParameterType; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @romanetar is this import used ? |
||
| use LaravelDoctrine\ORM\Facades\EntityManager; | ||
| use models\summit\Presentation; | ||
| use models\summit\SummitMetric; | ||
|
|
@@ -1832,25 +1834,47 @@ public function getLastNSponsorMemberships($last_n = 2) | |
| */ | ||
| public function getActiveSummitsSponsorMemberships() | ||
| { | ||
| $dql = <<<DQL | ||
| SELECT sp | ||
| FROM models\summit\Sponsor sp | ||
| JOIN sp.members m | ||
| JOIN sp.summit s | ||
| WHERE m.id = :member_id | ||
| AND s.end_date >= :now | ||
| ORDER BY s.begin_date ASC | ||
| DQL; | ||
|
|
||
| $query = $this->createQuery($dql); | ||
| return $query | ||
| ->setParameter('member_id', $this->getId()) | ||
| ->setParameter('now', new \DateTime('now', new \DateTimeZone('UTC'))) | ||
| ->getResult(); | ||
| // Step 1 — use native SQL (needed for JSON_CONTAINS) to collect IDs only. | ||
| $idSql = <<<SQL | ||
| SELECT sp.ID | ||
| FROM Sponsor sp | ||
| INNER JOIN Sponsor_Users su ON su.SponsorID = sp.ID | ||
| INNER JOIN Summit s ON s.ID = sp.SummitID | ||
| WHERE su.MemberID = :member_id | ||
| AND s.SummitEndDate >= :now | ||
| AND ( | ||
| JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) | ||
| OR JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_external)) | ||
| ) | ||
| ORDER BY s.SummitBeginDate ASC | ||
| SQL; | ||
|
|
||
| $stmt = $this->prepareRawSQL($idSql, [ | ||
| 'member_id' => $this->getId(), | ||
| 'now' => (new \DateTime('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'), | ||
| 'slug_sponsors' => IGroup::Sponsors, | ||
| 'slug_external' => IGroup::SponsorExternalUsers, | ||
| ]); | ||
| $ids = $stmt->executeQuery()->fetchFirstColumn(); | ||
|
|
||
| if (empty($ids)) { | ||
| return []; | ||
| } | ||
|
|
||
| // Step 2 — load all sponsors in a single IN query. findBy() uses PK-based hydration | ||
| // which avoids the ORM 3 assertion failure triggered by the OneToOne inverse associations | ||
| // on Sponsor (lead_report_setting, sponsorservices_statistics) that DQL/native-query | ||
| // hydration hits. The result set is then re-sorted to match the SQL ORDER BY. | ||
| $position = array_flip($ids); | ||
| $sponsors = EntityManager::getRepository(Sponsor::class)->findBy(['id' => $ids]); | ||
| usort($sponsors, fn($a, $b) => $position[$a->getId()] <=> $position[$b->getId()]); | ||
| return $sponsors; | ||
| } | ||
|
|
||
| /** | ||
| * @param Summit $summit | ||
| * @return array | ||
| * @throws Exception | ||
| */ | ||
| public function getSponsorMembershipIds(Summit $summit): array | ||
| { | ||
|
|
@@ -1859,11 +1883,17 @@ public function getSponsorMembershipIds(Summit $summit): array | |
| FROM Sponsor_Users | ||
| INNER JOIN Sponsor ON Sponsor.ID = Sponsor_Users.SponsorID | ||
| WHERE MemberID = :member_id AND Sponsor.SummitID = :summit_id | ||
| AND ( | ||
| JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) | ||
| OR JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_external)) | ||
| ) | ||
| SQL; | ||
|
|
||
| $stmt = $this->prepareRawSQL($sql, [ | ||
| 'member_id' => $this->getId(), | ||
| 'summit_id' => $summit->getId(), | ||
| $stmt = $this->prepareRawSQL($sql, [ | ||
| 'member_id' => $this->getId(), | ||
| 'summit_id' => $summit->getId(), | ||
| 'slug_sponsors' => IGroup::Sponsors, | ||
| 'slug_external' => IGroup::SponsorExternalUsers, | ||
| ]); | ||
| $res = $stmt->executeQuery(); | ||
| return $res->fetchFirstColumn(); | ||
|
|
@@ -1872,20 +1902,27 @@ public function getSponsorMembershipIds(Summit $summit): array | |
| public function hasSponsorMembershipsFor(Summit $summit, Sponsor $sponsor = null): bool | ||
| { | ||
| try { | ||
| $canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser(); | ||
| if(!$canHaveSponsorMemberships) return false; | ||
| $canHaveSponsorMemberships = $this->isSponsorUser() || $this->isExternalSponsorUser(); | ||
| if(!$canHaveSponsorMemberships) return false; | ||
|
|
||
| $sql = <<<SQL | ||
| SELECT COUNT(Sponsor_Users.SponsorID) | ||
| FROM Sponsor_Users | ||
| INNER JOIN Sponsor ON Sponsor.ID = Sponsor_Users.SponsorID | ||
| WHERE | ||
| MemberID = :member_id | ||
| AND Sponsor.SummitID = :summit_id | ||
| AND ( | ||
| JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) | ||
| OR JSON_CONTAINS(COALESCE(Sponsor_Users.Permissions, '[]'), JSON_QUOTE(:slug_external)) | ||
| ) | ||
| SQL; | ||
|
|
||
| $params = [ | ||
| 'member_id' => $this->getId(), | ||
| 'summit_id' => $summit->getId(), | ||
| $params = [ | ||
| 'member_id' => $this->getId(), | ||
| 'summit_id' => $summit->getId(), | ||
| 'slug_sponsors' => IGroup::Sponsors, | ||
| 'slug_external' => IGroup::SponsorExternalUsers, | ||
| ]; | ||
|
|
||
| if(!is_null($sponsor)) { | ||
|
|
@@ -1956,12 +1993,48 @@ public function addSummitRegistrationOrder(SummitOrder $summit_order) | |
|
|
||
| /** | ||
| * @param Summit $summit | ||
| * @return ArrayCollection | ||
| * @throws Exception | ||
| */ | ||
| public function getAllowedSponsorsBySummit(Summit $summit): ArrayCollection | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @romanetar change the name of the method to something that communicate the intention getAccessibleSponsorsBySummit
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also this method is doing the same as getActiveSummitsSponsorMemberships lets unify it
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also for the raw sql part it could reuse getSponsorMembershipIds |
||
| { | ||
| $sql = <<<SQL | ||
| SELECT su.SponsorID | ||
| FROM Sponsor_Users su | ||
| INNER JOIN Sponsor s ON s.ID = su.SponsorID | ||
| WHERE su.MemberID = :member_id | ||
| AND s.SummitID = :summit_id | ||
| AND ( | ||
| JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_sponsors)) | ||
| OR JSON_CONTAINS(COALESCE(su.Permissions, '[]'), JSON_QUOTE(:slug_external)) | ||
| ) | ||
| SQL; | ||
| $ids = $this->prepareRawSQL($sql, [ | ||
| 'member_id' => $this->getId(), | ||
| 'summit_id' => $summit->getId(), | ||
| 'slug_sponsors' => IGroup::Sponsors, | ||
| 'slug_external' => IGroup::SponsorExternalUsers, | ||
| ])->executeQuery()->fetchFirstColumn(); | ||
|
|
||
| if (empty($ids)) { | ||
| return new ArrayCollection(); | ||
| } | ||
|
|
||
| $position = array_flip($ids); | ||
| $sponsors = $this->getEM()->getRepository(Sponsor::class)->findBy(['id' => $ids]); | ||
| usort($sponsors, fn($a, $b) => $position[$a->getId()] <=> $position[$b->getId()]); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @romanetar this is an overkill ( usort ) just do ordering like this |
||
| return new ArrayCollection($sponsors); | ||
| } | ||
|
|
||
| /** | ||
| * @param Summit $summit | ||
| * @param int $sponsor_id | ||
| * @return Sponsor|null | ||
| */ | ||
| public function getSponsorBySummit(Summit $summit): ?Sponsor | ||
| public function getSponsorBySummitAndId(Summit $summit, int $sponsor_id): ?Sponsor | ||
| { | ||
| $sponsor = $this->sponsor_memberships->filter(function ($entity) use ($summit) { | ||
| return $entity->getSummitId() == $summit->getId(); | ||
| $sponsor = $this->sponsor_memberships->filter(function ($entity) use ($summit, $sponsor_id) { | ||
| return $entity->getSummitId() == $summit->getId() && $entity->getId() == $sponsor_id; | ||
| })->first(); | ||
|
|
||
| return $sponsor === false ? null : $sponsor; | ||
|
|
@@ -3412,6 +3485,97 @@ public function getIndividualMemberJoinDate(): ?\DateTime | |
| return $this->individual_member_join_date; | ||
| } | ||
|
|
||
| /** | ||
| * Appends $group_slug to the Permissions JSON array on the Sponsor_Users row | ||
| * for this member and the given sponsor. Idempotent: the slug is only added | ||
| * when it is not already present. | ||
| * | ||
| * An exclusive row lock (SELECT … FOR UPDATE) is acquired first so that | ||
| * concurrent jobs for the same (member, sponsor, slug) serialize here and | ||
| * the second job always reads the post-first-job value, preventing duplicates. | ||
| * | ||
| * Returns the number of rows matched by the WHERE clause (0 when the | ||
| * Sponsor_Users row does not yet exist, 1 when it does). | ||
| */ | ||
| public function addSponsorPermission(int $sponsor_id, string $group_slug): int | ||
| { | ||
| // Lock the row before the read-modify-write so concurrent transactions | ||
| // serialize and the IF(JSON_CONTAINS) in the UPDATE sees the committed state. | ||
| $this->prepareRawSQL( | ||
| 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE', | ||
| ['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()] | ||
| )->executeQuery(); | ||
|
|
||
| $sql = <<<SQL | ||
| UPDATE Sponsor_Users | ||
romanetar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| SET Permissions = IF( | ||
| JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug)), | ||
| Permissions, | ||
| JSON_ARRAY_APPEND(COALESCE(Permissions, '[]'), '$', :group_slug) | ||
| ) | ||
| WHERE SponsorID = :sponsor_id AND MemberID = :member_id | ||
| SQL; | ||
| return $this->prepareRawSQL($sql, [ | ||
| 'group_slug' => $group_slug, | ||
| 'sponsor_id' => $sponsor_id, | ||
| 'member_id' => $this->getId(), | ||
| ])->executeStatement(); | ||
| } | ||
|
|
||
| /** | ||
| * Removes $group_slug from the Permissions JSON array on the Sponsor_Users row | ||
| * for this member and the given sponsor, then returns how many other Sponsor_Users | ||
| * rows for this member still carry that slug. The caller uses the count to decide | ||
| * whether to also revoke the global group membership. | ||
| * | ||
| * An exclusive row lock is acquired first so the remove UPDATE and the | ||
| * remaining-count SELECT are not interleaved with concurrent operations. | ||
| * All occurrences of the slug are removed (via JSON_ARRAYAGG filter) to | ||
| * prevent stale entries if a prior race introduced duplicates. | ||
| */ | ||
| public function removeSponsorPermission(int $sponsor_id, string $group_slug): int | ||
| { | ||
| // Serialize concurrent removals for the same row. | ||
| $this->prepareRawSQL( | ||
| 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = :sponsor_id AND MemberID = :member_id FOR UPDATE', | ||
| ['sponsor_id' => $sponsor_id, 'member_id' => $this->getId()] | ||
| )->executeQuery(); | ||
|
|
||
| // Remove ALL occurrences (not just the first) so duplicate slugs | ||
| // introduced by any prior race cannot leave stale entries behind. | ||
| $removeSQL = <<<SQL | ||
| UPDATE Sponsor_Users | ||
| SET Permissions = COALESCE( | ||
| ( | ||
| SELECT JSON_ARRAYAGG(element) | ||
| FROM JSON_TABLE( | ||
| COALESCE(Permissions, '[]'), '$[*]' | ||
| COLUMNS(element VARCHAR(255) PATH '$') | ||
| ) AS jt | ||
| WHERE element != :group_slug | ||
| ), | ||
| '[]' | ||
| ) | ||
| WHERE SponsorID = :sponsor_id AND MemberID = :member_id | ||
| AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug)) | ||
| SQL; | ||
| $this->prepareRawSQL($removeSQL, [ | ||
| 'group_slug' => $group_slug, | ||
| 'sponsor_id' => $sponsor_id, | ||
| 'member_id' => $this->getId(), | ||
| ])->executeStatement(); | ||
|
|
||
| $countSQL = <<<SQL | ||
| SELECT COUNT(*) FROM Sponsor_Users | ||
| WHERE MemberID = :member_id | ||
| AND JSON_CONTAINS(COALESCE(Permissions, '[]'), JSON_QUOTE(:group_slug)) | ||
| SQL; | ||
| return intval($this->prepareRawSQL($countSQL, [ | ||
| 'member_id' => $this->getId(), | ||
| 'group_slug' => $group_slug, | ||
| ])->executeQuery()->fetchOne()); | ||
| } | ||
|
|
||
| public function addSponsorMembership(Sponsor $sponsor):void | ||
| { | ||
| if($this->sponsor_memberships->contains($sponsor)) return; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.