You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(promo-codes): address review follow-ups for Tasks 8 and 9
Task 8: wrap INSTANCE OF chain in parentheses to preserve summit
scoping, simplify speaker email matching via getOwnerEmail(), and
exclude cancelled tickets from quantity-per-account count.
Task 9: add remaining_quantity_per_account (null) to all four
member/speaker serializers, re-add allowed_ticket_types to member
and speaker discount code serializers, and declare setter/getter
on IDomainAuthorizedPromoCode interface.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
+5-7Lines changed: 5 additions & 7 deletions
Original file line number
Diff line number
Diff line change
@@ -684,7 +684,7 @@ public function getDiscoverableByEmailForSummit(Summit $summit, string $email):
684
684
->from($this->getBaseEntity(), 'e')
685
685
->leftJoin('e.summit', 's')
686
686
->where('s.id = :summit_id')
687
-
->andWhere("e INSTANCE OF {$daDiscountClass} OR e INSTANCE OF {$daPromoClass} OR e INSTANCE OF {$memberPromoClass} OR e INSTANCE OF {$memberDiscountClass} OR e INSTANCE OF {$speakerPromoClass} OR e INSTANCE OF {$speakerDiscountClass}")
687
+
->andWhere("(e INSTANCE OF {$daDiscountClass} OR e INSTANCE OF {$daPromoClass} OR e INSTANCE OF {$memberPromoClass} OR e INSTANCE OF {$memberDiscountClass} OR e INSTANCE OF {$speakerPromoClass} OR e INSTANCE OF {$speakerDiscountClass})")
688
688
->setParameter('summit_id', $summit->getId());
689
689
690
690
$candidates = $qb->getQuery()->getResult();
@@ -709,12 +709,9 @@ public function getDiscoverableByEmailForSummit(Summit $summit, string $email):
709
709
}
710
710
711
711
if ($codeinstanceof SpeakerSummitRegistrationPromoCode || $codeinstanceof SpeakerSummitRegistrationDiscountCode) {
712
-
$speaker = $code->getSpeaker();
713
-
if (!is_null($speaker) && $speaker->hasMember()) {
714
-
$member = $speaker->getMember();
715
-
if (!is_null($member) && strtolower($member->getEmail()) === $email && $code->isLive()) {
716
-
$results[] = $code;
717
-
}
712
+
$ownerEmail = $code->getOwnerEmail();
713
+
if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
714
+
$results[] = $code;
718
715
}
719
716
continue;
720
717
}
@@ -739,6 +736,7 @@ public function getTicketCountByMemberAndPromoCode(Member $member, SummitRegistr
Copy file name to clipboardExpand all lines: doc/promo-codes-for-early-registration-access.md
+15-2Lines changed: 15 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -573,7 +573,9 @@ Deviations from the SDS captured during implementation. Each entry is either **O
573
573
- Unit test for discovery query
574
574
575
575
**Review Follow-ups:**
576
-
- None
576
+
- [x] **Summit scoping lost in DQL OR chain (MUST-FIX):** `getDiscoverableByEmailForSummit()` at line 683 builds `->where('s.id = :summit_id')->andWhere("e INSTANCE OF A OR e INSTANCE OF B OR ...")`. Doctrine's `andWhere()` wraps existing + new conditions in an `Andx` composite that renders as `(s.id = :summit_id AND e INSTANCE OF A OR e INSTANCE OF B OR ...)`. Due to SQL/DQL operator precedence (AND before OR), only the first `INSTANCE OF` branch is summit-scoped; all remaining branches match those types from any summit, leaking cross-summit promo codes into discovery results. **Fix:** wrap the entire `INSTANCE OF` chain in an extra pair of parentheses so it is treated as a single group: `->andWhere("(e INSTANCE OF {$daDiscountClass} OR e INSTANCE OF {$daPromoClass} OR e INSTANCE OF {$memberPromoClass} OR e INSTANCE OF {$memberDiscountClass} OR e INSTANCE OF {$speakerPromoClass} OR e INSTANCE OF {$speakerDiscountClass})")`. File: `app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`, line 687.
577
+
- [x] **Speaker email matching misses speakers without a linked Member (SHOULD-FIX):** `getDiscoverableByEmailForSummit()` at lines 711–720 guards on `$speaker->hasMember()` then accesses `$speaker->getMember()->getEmail()`. However, `PresentationSpeaker::getEmail()` (Speakers/PresentationSpeaker.php:1924) already falls through to `$this->registration_request->getEmail()` when no Member association exists. `SpeakerSummitRegistrationPromoCode::getOwnerEmail()` and `SpeakerSummitRegistrationDiscountCode::getOwnerEmail()` both call `$this->getSpeaker()->getEmail()` which uses this fallback. `SpeakerPromoCodeTrait::checkSubject()` validates via `getOwnerEmail()`. The discovery code and `checkSubject` are inconsistent: a speaker code whose speaker has only a `SpeakerRegistrationRequest` (no Member) passes checkout validation but is never returned by discovery. **Fix:** replace the `hasMember()` guard + `getMember()->getEmail()` path with a direct call to `$code->getOwnerEmail()` (which already exists on both speaker promo code types via `IOwnablePromoCode`): `$ownerEmail = $code->getOwnerEmail(); if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) { $results[] = $code; }`. File: `app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`, lines 711–719.
578
+
-[x]**`getTicketCountByMemberAndPromoCode` counts cancelled tickets (SHOULD-FIX):** The raw SQL at lines 735–742 filters by `o.Status IN ('Paid', 'Confirmed')` (order status) but does not filter by ticket status. `SummitAttendeeTicket` has its own `Status` column — `isCancelled()` at SummitAttendeeTicket.php:559 checks against `IOrderConstants::CancelledStatus`. A ticket can be individually cancelled within a paid order without changing the order status. Such cancelled tickets are still counted toward `quantity_per_account`, over-inflating the count and potentially blocking users who cancelled and want to repurchase. **Fix:** add `AND t.Status != 'Cancelled'` to the WHERE clause (or equivalently `AND t.Status = 'Paid'` if only Paid is a valid active status for tickets). The constant value is `IOrderConstants::CancelledStatus = 'Cancelled'`. File: `app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`, line 741.
577
579
578
580
---
579
581
@@ -697,7 +699,18 @@ Deviations from the SDS captured during implementation. Each entry is either **O
697
699
- Integration test calling the endpoint
698
700
699
701
**Review Follow-ups:**
700
-
- None
702
+
703
+
-[x]**`remaining_quantity_per_account` absent from member/speaker serializer output (MUST-FIX):**
704
+
All four member/speaker serializers (`MemberSummitRegistrationPromoCodeSerializer`, `MemberSummitRegistrationDiscountCodeSerializer`, `SpeakerSummitRegistrationPromoCodeSerializer`, `SpeakerSummitRegistrationDiscountCodeSerializer`) do not output `remaining_quantity_per_account`. The DoD requires every discover result to include this field, and the SDS sample response shows `"remaining_quantity_per_account": null` for `MEMBER_DISCOUNT_CODE` and `SPEAKER_PROMO_CODE`. The domain-authorized serializers correctly set it from a transient property; member/speaker serializers must emit `null` unconditionally (these types have no per-account limit concept).
705
+
**Fix:** In the `serialize()` override of each of the four member/speaker serializers, add `$values['remaining_quantity_per_account'] = null;` before returning `$values`. No entity change required — member/speaker entities do not need a transient property; the value is always `null` for these types.
706
+
707
+
-[x]**`allowed_ticket_types` absent from member/speaker discount code responses (MUST-FIX):**
708
+
`SummitRegistrationDiscountCodeSerializer::serialize()` unconditionally calls `unset($values['allowed_ticket_types'])` (line 46). `MemberSummitRegistrationDiscountCodeSerializer` and `SpeakerSummitRegistrationDiscountCodeSerializer` both extend this class and never re-add the key, so `MEMBER_DISCOUNT_CODE` and `SPEAKER_DISCOUNT_CODE` results from the discover endpoint are missing `allowed_ticket_types`. `DomainAuthorizedSummitRegistrationDiscountCodeSerializer` already demonstrates the correct fix pattern at lines 47–56: check `in_array('allowed_ticket_types', $relations)` and rebuild the array from `$code->getAllowedTicketTypes()`.
709
+
**Fix:** In `MemberSummitRegistrationDiscountCodeSerializer::serialize()` and `SpeakerSummitRegistrationDiscountCodeSerializer::serialize()`, after calling `parent::serialize()`, re-add `allowed_ticket_types` using the same pattern as `DomainAuthorizedSummitRegistrationDiscountCodeSerializer.php:47–56`. The controller's default `$relations` already includes `'allowed_ticket_types'`, so no controller change is needed.
`SummitPromoCodeService::discoverPromoCodes()` narrows a code to `IDomainAuthorizedPromoCode` via `instanceof`, then calls `$code->setRemainingQuantityPerAccount(...)` (service lines 1035, 1037). The interface (`IDomainAuthorizedPromoCode.php`) declares only `getAllowedEmailDomains()`, `getQuantityPerAccount()`, and `matchesEmailDomain()` — neither setter nor getter is declared. PHP resolves the call dynamically at runtime (both concrete classes `DomainAuthorizedSummitRegistrationPromoCode` and `DomainAuthorizedSummitRegistrationDiscountCode` implement both methods), but static analysis tools (PHPStan/Psalm) will flag this as a call on an undefined method of the interface type.
713
+
**Fix:** Add `public function setRemainingQuantityPerAccount(?int $remaining): void;` and `public function getRemainingQuantityPerAccount(): ?int;` to `IDomainAuthorizedPromoCode.php`. Both concrete classes already implement these methods, so no implementation change is needed — only the interface declaration.
0 commit comments