Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function getAddValidationRules(array $payload): array
return [
'qr_code' => 'required_without:attendee_email|string',
'attendee_email' => 'required_without:qr_code|email',
'sponsor_id' => 'sometimes|integer',
'scan_date' => 'required|date_format:U|epoch_seconds',
'notes' => 'sometimes|string|max:1024',
'extra_questions' => 'sometimes|extra_question_dto_array',
Expand All @@ -115,6 +116,9 @@ function getCheckInValidationRules(): array
* @param Summit $summit
* @param array $payload
* @return IEntity
* @throws EntityNotFoundException
* @throws HTTP403ForbiddenException
* @throws ValidationException
*/
protected function addChild(Summit $summit, array $payload): IEntity
{
Expand Down Expand Up @@ -381,7 +385,7 @@ function($filter) use($summit, $current_member){
if (!is_null($current_member)){
if ($current_member->isAuthzFor($summit)) return $filter;
// add filter for sponsor user
if ($current_member->isSponsorUser()) {
if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) {
$sponsor_ids = $current_member->getSponsorMembershipIds($summit);
// is allowed sponsors are empty, add dummy value
if (!count($sponsor_ids)) $sponsor_ids[] = 0;
Expand Down Expand Up @@ -506,7 +510,7 @@ function($filter) use($summit, $current_member){
if (!is_null($current_member)){
if ($current_member->isAuthzFor($summit)) return $filter;
// add filter for sponsor user
if ($current_member->isSponsorUser()) {
if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) {
$sponsor_ids = $current_member->getSponsorMembershipIds($summit);
// is allowed sponsors are empty, add dummy value
if (!count($sponsor_ids)) $sponsor_ids[] = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ protected function applyExtraFilters(Filter $filter):Filter {
// check AUTHZ for sponsors
if($current_member->isAuthzFor($summit)) return $filter;
// add filter for sponsor user
if ($current_member->isSponsorUser()) {
if ($current_member->isSponsorUser() || $current_member->isExternalSponsorUser()) {
$sponsor_ids = $current_member->getSponsorMembershipIds($summit);
// is allowed sponsors are empty, add dummy value
if (!count($sponsor_ids)) $sponsor_ids[] = 0;
Expand Down
16 changes: 13 additions & 3 deletions app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add check for $payload['data'] existence before accessing it.

Line 62 accesses $payload['data'] without verifying the key exists. If the payload lacks a data key, this will throw an undefined array key error before reaching the validation on lines 63-65.

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
Verify each finding against the current code and only fix it if needed.

In `@app/Jobs/SponsorServices/UpdateSponsorMemberGroupsMQJob.php` around lines 62
- 65, The code in UpdateSponsorMemberGroupsMQJob currently reads
$payload['data'] without verifying the key exists; update the handler (the
method containing the $payload usage in UpdateSponsorMemberGroupsMQJob) to first
check that isset($payload['data']) and that it is an array (or non-empty) and
throw a ValidationException with a clear message if missing, then assign $data =
$payload['data'] and proceed with the existing validation for user_external_id,
group_slug, sponsor_id and summit_id; ensure you reference the same $data
variable used later so the subsequent isset checks don't trigger undefined index
warnings.


$user_external_id = intval($data['user_external_id']);
$group_slug = $data['group_slug'];
$sponsor_id = intval($data['sponsor_id']);
$summit_id = intval($data['summit_id']);

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) {
Expand All @@ -83,4 +93,4 @@ public function failed(array $data, Throwable $exception): void
{
Log::error("UpdateSponsorMemberGroupsMQJob::failed {$exception->getMessage()}");
}
}
}
216 changes: 190 additions & 26 deletions app/Models/Foundation/Main/Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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;
Expand Down Expand Up @@ -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
{
Expand All @@ -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();
Expand All @@ -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)) {
Expand Down Expand Up @@ -1956,12 +1993,48 @@ public function addSummitRegistrationOrder(SummitOrder $summit_order)

/**
* @param Summit $summit
* @return ArrayCollection
* @throws Exception
*/
public function getAllowedSponsorsBySummit(Summit $summit): ArrayCollection
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
like

getAccessibleSponsorsBySummit

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this method is doing the same as getActiveSummitsSponsorMemberships lets unify it

Copy link
Copy Markdown
Collaborator

@smarcet smarcet Apr 9, 2026

Choose a reason for hiding this comment

The 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()]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romanetar this is an overkill ( usort ) just do ordering like this

if (!$ids) {
       return new ArrayCollection();
   }

   $sponsors = $this->getEM()
       ->createQuery('SELECT s FROM App\Entity\Sponsor s WHERE s.id IN (:ids) ORDER BY s.id ASC')
       ->setParameter('ids', $ids)
       ->getResult();

   return new ArrayCollection($sponsors);

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;
Expand Down Expand Up @@ -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
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;
Expand Down
Loading
Loading