feat(promo-codes): domain-authorized promo codes for early registration access#525
feat(promo-codes): domain-authorized promo codes for early registration access#525caseylocker wants to merge 36 commits intomainfrom
Conversation
…registration access Adds two new promo code subtypes (DomainAuthorizedSummitRegistrationDiscountCode and DomainAuthorizedSummitRegistrationPromoCode) enabling domain-based early registration access. Adds WithPromoCode ticket type audience value for promo-code-only distribution. Includes auto-discovery endpoint, per-account quantity enforcement at checkout, and auto_apply support for existing member/speaker promo code types. SDS: doc/promo-codes-for-early-registration-access.md Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Task 1: Add ClassName discriminator ENUM widening in migration, add data guard before narrowing Audience ENUM in down() - Task 2: Guard matchesEmailDomain() against emails missing @ to prevent false-positive suffix matches - Task 3: Replace canBeAppliedTo() with direct collection membership check in addTicketTypeRule() (Truth #4), override removeTicketTypeRule() to prevent parent from re-adding to allowed_ticket_types Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Task 5: accepted NITs for constant naming, interface gap, and pre-existing edge cases. Task 7: MUST-FIX — canBuyRegistrationTicketByType() missing WithPromoCode branch blocks checkout for promo-code-only tickets. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Replace 'sometimes|json' with custom AllowedEmailDomainsArray rule for allowed_email_domains validation — accepts pre-decoded PHP array and validates each entry against @domain.com/.tld/user@email formats - Remove json_decode() from factory populate for both domain-authorized types — value is already a PHP array after request decoding - Fix expand=allowed_ticket_types silently dropping field on DomainAuthorizedSummitRegistrationDiscountCodeSerializer — extend re-add guard to check both $relations and $expand - Rename json_array → json_string_array in both new serializers Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
Add WithPromoCode branch to canBuyRegistrationTicketByType() so promo-code-only ticket types are not rejected at checkout for both invited and non-invited users. Replace isSoldOut() with canSell() in the strategy's WithPromoCode loop to align listing visibility with checkout enforcement. Add 5 unit tests for audience-based filtering scenarios required by Task 7 DoD. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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) <[email protected]>
…ity_per_account Move ApplyPromoCodeTask after ReserveOrderTask in the saga chain so ticket rows exist when the count query fires. Broaden getTicketCountByMemberAndPromoCode to include 'Reserved' orders, ensuring concurrent checkouts correctly see each other's reservations. Remove the TOCTOU-vulnerable pre-check from PreProcessReservationTask and relocate it inside ApplyPromoCodeTask's locked transaction, where it naturally fires once per unique promo code. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…sion, canBeAppliedTo, discovery, checkout - Fix base class: extend Tests\TestCase instead of PHPUnit\Framework\TestCase (boots Laravel facades) - Add 3 collision avoidance tests for DomainAuthorizedSummitRegistrationDiscountCode - Add 2 canBeAppliedTo override tests (free ticket guard bypass) - Add 4 auto_apply tests for existing email-linked types (Member/Speaker promo/discount) - Fix vacuous testWithPromoCodeAudienceNoPromoCodeNotReturned (now asserts on real data) - Add 3 serializer tests (auto_apply, remaining_quantity_per_account, email-linked type) - Rename misleading test to testWithPromoCodeAudienceLivePromoCodeReturned - Add 5 discovery endpoint integration tests in OAuth2SummitPromoCodesApiTest - Add 3 checkout enforcement test stubs (2 need order pipeline harness, 1 blocked by D4) - Mark all 9 review follow-ups complete in SDS doc Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds domain-authorized promo/discount code types with email-domain validation and per-account limits, adds an auto-apply flag across promo types, exposes a discover endpoint for authenticated members, enforces promo-code-only ticket purchases and per-account limits during reservation, and updates serializers, repository logic, migrations, and tests. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Authenticated Client
participant Controller as OAuth2 Controller
participant Service as PromoCodeService
participant Repository as PromoCodeRepo
participant DB as Database
Client->>Controller: GET /promo-codes/all/discover
Controller->>Controller: Load Summit & Current User
Controller->>Service: discoverPromoCodes(summit, member)
Service->>Repository: getDiscoverableByEmailForSummit(summit, email)
Repository->>DB: Query promo/discount codes by email/domain/subtype
DB-->>Repository: Return matching codes
Repository-->>Service: codes[]
loop Filter Each Code
Service->>Service: Check isLive() & hasQuantityAvailable()
Service->>Repository: getTicketCountByMemberAndPromoCode() (for domain-authorized)
Repository-->>Service: usedCount
Service->>Service: Enforce per-account limit & set remaining_quantity_per_account
end
Service-->>Controller: Filtered codes[]
Controller->>Controller: Serialize each code (expand/fields/relations)
Controller-->>Client: JSON {data: [], total, per_page, current_page, last_page}
sequenceDiagram
participant Order as OrderService
participant PreProcess as PreProcessReservationTask
participant Reserve as ReserveOrderTask
participant Apply as ApplyPromoCodeTask
participant Repo as PromoCodeRepo
participant Promo as PromoCode Model
Order->>PreProcess: run() - validate tickets
PreProcess->>PreProcess: Reject if isPromoCodeOnly() and no promo_code
PreProcess-->>Order: reservations state
Order->>Reserve: run() - reserve tickets
Reserve-->>Order: order & tickets created
Order->>Apply: run() - apply promo code
Apply->>Repo: Load promo code
Repo-->>Apply: PromoCode
Apply->>Promo: If IDomainAuthorizedPromoCode?
alt Domain-Authorized
Apply->>Repo: getTicketCountByMemberAndPromoCode()
Repo-->>Apply: usedCount
Apply->>Promo: Check usedCount < getQuantityPerAccount()
alt Over Limit
Apply-->>Order: ValidationException
else Under Limit
Apply->>Promo: Apply redemption
Apply-->>Order: Success
end
else Regular Code
Apply->>Promo: Apply redemption
Apply-->>Order: Success
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
The GET /api/v1/summits/{id}/promo-codes/all/discover route was added in
Task 12 but never seeded into the api_endpoints table. The OAuth2 bearer
token validator middleware rejects any unregistered route with a 400
"API endpoint does not exits" error, causing 5 discover-endpoint integration
tests in OAuth2SummitPromoCodesApiTest to fail.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
The discover endpoint's seeder entry intentionally omits authz_groups per
SDS Task 9 ("any authenticated user with read scope"). The auth.user
middleware requires at least one matching group, so every request fell
through to a 403. Switch to rate.limit:25,1 to match the adjacent
pre-validate-promo-code route, which has the same "any authenticated user"
profile. OAuth2 bearer auth and scope enforcement are still applied via
the parent 'api' middleware group.
All 5 discover integration tests now pass (verified locally).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
…ted discovery codes Two review findings on the promo-codes branch. P1 — `POST /orders` allowed reserving audience=WithPromoCode ticket types with just a `type_id` and no `promo_code`. `Summit::canBuyRegistrationTicketByType` unconditionally returns true for that audience, and `PreProcessReservationTask::run` only validated a promo code when one was supplied. Add an explicit `isPromoCodeOnly() && empty($promo_code_value)` guard that throws ValidationException; reuses the existing `SummitTicketType::isPromoCodeOnly()` helper. P2 — Promo code discovery endpoint returned globally exhausted finite codes (`quantity_used >= quantity_available`). The repository filter uses `isLive()` which is dates-only, and the service layer only enforced the per-account quota. Add a `hasQuantityAvailable()` short-circuit at the top of `SummitPromoCodeService::discoverPromoCodes` so discovery matches `validate()` behavior at checkout. Regression tests: - `tests/Unit/Services/PreProcessReservationTaskTest.php` — pure PHPUnit unit tests for the WithPromoCode guard (reject + non-overreach). - `tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php` — pure PHPUnit unit tests for the global exhaustion filter (reject, healthy passes, mixed batch). - `tests/oauth2/OAuth2SummitPromoCodesApiTest.php` — `testDiscoverExcludesGloballyExhaustedCodes`, sibling to the existing per-account exhaustion integration test. Mutation-verified: temporarily reverted both fixes, confirmed that 3 of 5 new unit tests fail as expected, then restored. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
Follow-up to b87cefd addressing Codex review suggestions #2 and #4. PreProcessReservationTaskTest: add two mixed-payload tests exercising the per-ticket guard in heterogeneous reservations (promo-only + Audience_All), both orderings. The original tests only covered single-ticket payloads. - testRejectsMixedPayloadWithPromoCodeOnlyFirst — guard fires on first iter. - testRejectsMixedPayloadWithPromoCodeOnlySecond — guard fires after prior aggregation; proves the exception short-circuits cleanly. SummitPromoCodeServiceDiscoveryTest: add an infinite-code overreach test that pins the `quantity_available == 0` semantics — `hasQuantityAvailable()` short-circuits to true for infinite codes, so the exhaustion guard must not drop them. - testDiscoverReturnsInfiniteDomainAuthorizedCode. Mutation-verified: reverting the production fixes causes the 3 reject tests to fail while the infinite-code and healthy-code tests still pass, as expected for overreach guards. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
Serializer unit tests (testSerializerAutoApplyField, testSerializerRemainingQuantityPerAccount, testSerializerAutoApplyEmailLinkedType) were failing because bare model instances lacked a Summit association, causing getSummitId() to call getId() on null. Added buildMockSummitForSerializer() helper and setSummit() calls in all three tests. Updated D3 deviation status to RESOLVED — AllowedEmailDomainsArray custom rule was already implemented. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
app/Services/Model/Imp/SummitOrderService.php (2)
284-296:⚠️ Potential issue | 🔴 CriticalThe saga state is dropped before
ApplyPromoCodeTask.After this reorder,
ApplyPromoCodeTask::run()no longer receivespromo_codes_usagebecauseReserveOrderTask::run()replaces the state with['order' => $order]. That means promo-code redemption/enforcement now runs with missing input, and any failure happens after the order was already persisted whileReserveOrderTask::undo()is still empty.🛠️ Minimal state fix
- return ['order' => $order]; + return array_merge($this->formerState, ['order' => $order]);Also applies to: 667-674
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Services/Model/Imp/SummitOrderService.php` around lines 284 - 296, ReserveOrderTask::run() currently replaces the saga state (losing keys like promo_codes_usage) so ApplyPromoCodeTask::run() no longer receives required promo data; fix by preserving/merging state instead of overwriting (ensure ReserveOrderTask::run() returns array_merge($existingState, ['order' => $order]) or similar), or alternatively move the ApplyPromoCodeTask invocation to run before ReserveOrderTask so promo redemption happens prior to persisting the order; also apply the same fix at the other occurrence referenced (lines ~667-674) and ensure undo() implementations handle partial persistence.
728-742:⚠️ Potential issue | 🟠 MajorGuest/public reservations can bypass
QuantityPerAccount.This check is skipped whenever the saga was built with a null
$owner, butReserveOrderTaskcan later attach the order to an existing member viaowner_email. In that flow, the per-account limit is never enforced even though the order now belongs to an account. Resolve the owner from the reserved order (or byowner_email) before counting usage.Also applies to: 791-809
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Services/Model/Imp/SummitOrderService.php` around lines 728 - 742, The constructor currently stores a nullable $owner causing per-account checks (QuantityPerAccount) to be skipped; update the reserve flow so that before enforcing QuantityPerAccount (in ReserveOrderTask and the methods around the QuantityPerAccount check referenced at lines ~791-809) you resolve the actual owner from the reserved order (or by owner_email) instead of relying on the initial $this->owner being non-null; specifically, locate the QuantityPerAccount enforcement code and change it to obtain the owner via the reserved Order object (or lookup by owner_email) and then perform the per-account usage count against that resolved Member before allowing the reservation to proceed.
🧹 Nitpick comments (10)
app/Models/Foundation/Summit/Summit.php (1)
5555-5568: Keep promo-code visibility separate from purchase authorization.Returning
truehere meansmodels\summit\Summit::canBuyRegistrationTicketByType()no longer answers “can this email buy this ticket type?” formodels\summit\SummitTicketType::Audience_With_Promo_Code; it only says the ticket is not invitation-gated. That makes the helper easy to reuse incorrectly later and accidentally bypass the promo-code requirement. Consider threading promo-code context into this check, or splitting visibility from authorization into separate helpers.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/Foundation/Summit/Summit.php` around lines 5555 - 5568, The current Summit::canBuyRegistrationTicketByType() unconditionally returns true for SummitTicketType::Audience_With_Promo_Code which conflates visibility with purchase authorization; instead remove the unconditional return and either (a) add a promo-context parameter (e.g. $promoCodeProvided or $promoContext) to canBuyRegistrationTicketByType and check promo validity before granting purchase, or (b) split responsibilities by creating a separate visibility helper (e.g. isTicketTypeVisibleToEmail or canViewTicketType) that returns true for Audience_With_Promo_Code while leaving canBuyRegistrationTicketByType to enforce promo-code checks; update callers to pass the promo context or use the new visibility helper as appropriate.app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php (1)
1585-1602: OpenAPI annotation missingfieldsandrelationsquery parameters.The implementation at lines 1617-1622 reads
fieldsandrelationsfrom the request, but the OpenAPI annotation only documents theexpandparameter. Consider adding the missing parameters for complete API documentation.parameters: [ new OA\Parameter(name: "id", in: "path", required: true, schema: new OA\Schema(type: "integer")), new OA\Parameter(name: "expand", in: "query", required: false, schema: new OA\Schema(type: "string")), + new OA\Parameter(name: "fields", in: "query", required: false, schema: new OA\Schema(type: "string")), + new OA\Parameter(name: "relations", in: "query", required: false, schema: new OA\Schema(type: "string")), ],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php` around lines 1585 - 1602, The OpenAPI annotation for the controller method (the OA\Get block above discoverPromoCodesBySummit in OAuth2SummitPromoCodesApiController) is missing the query parameters used in the implementation; add two OA\Parameter entries for "fields" and "relations" (both in="query", required=false, schema type="string") with brief descriptions (e.g., "Comma-separated list of fields to include" and "Comma-separated list of relations to expand") so the documented parameters match the code that reads fields and relations from the request.app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php (1)
668-721: Discovery filtering logic is correct but has minor duplication.The method correctly:
- Normalizes email to lowercase
- Uses
INSTANCE OFto fetch only discoverable code types- Applies type-specific matching (domain vs. owner email)
- Checks
isLive()for all codesThe member and speaker code blocks (lines 702-716) share identical logic. Consider extracting to reduce duplication:
♻️ Optional refactor to reduce duplication
- // Email-linked types: match by associated member/speaker email - if ($code instanceof MemberSummitRegistrationPromoCode || $code instanceof MemberSummitRegistrationDiscountCode) { - $ownerEmail = $code->getOwnerEmail(); - if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) { - $results[] = $code; - } - continue; - } - - if ($code instanceof SpeakerSummitRegistrationPromoCode || $code instanceof SpeakerSummitRegistrationDiscountCode) { - $ownerEmail = $code->getOwnerEmail(); - if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) { - $results[] = $code; - } - continue; - } + // Email-linked types: match by associated member/speaker email + if (method_exists($code, 'getOwnerEmail')) { + $ownerEmail = $code->getOwnerEmail(); + if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) { + $results[] = $code; + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php` around lines 668 - 721, The member and speaker owner-email checks inside getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a small private helper (e.g., private function isOwnerEmailMatch($code, string $email): bool) that reads $code->getOwnerEmail(), lowercases and compares to $email and checks $code->isLive(), then replace the two separate blocks (the checks against MemberSummitRegistrationPromoCode, MemberSummitRegistrationDiscountCode and against SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with either a single instanceof-any conditional that calls the helper or two concise calls to the helper, leaving domain-authorized logic unchanged.app/Rules/AllowedEmailDomainsArray.php (1)
70-73:trans()used with literal string instead of translation key.The
trans()function expects a translation key, not a literal message. As written, it will return the string as-is. For proper i18n support:- return trans('The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or [email protected]).'); + return 'The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or [email protected]).';Or define a proper translation key if i18n is required.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Rules/AllowedEmailDomainsArray.php` around lines 70 - 73, The message() method in AllowedEmailDomainsArray currently calls trans() with a literal string; replace it to use a translation key (e.g. use __('validation.allowed_email_domains_array') or trans('validation.allowed_email_domains_array')) in the message() method and add the corresponding key/value in your language files (resources/lang/{locale}/validation.php) so i18n works correctly; reference: AllowedEmailDomainsArray::message().app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php (1)
21-48: Consider addingexpand_mappingsforallowed_ticket_types.The promo code model includes an
allowed_ticket_typesrelationship (inherited fromSummitRegistrationPromoCode), and the parallelDomainAuthorizedSummitRegistrationDiscountCodeSerializerincludesexpand_mappingsfor this field. For API consistency, add the same mapping to the promo code serializer:+ protected static $expand_mappings = [ + 'allowed_ticket_types' => [ + 'type' => \Libs\ModelSerializers\Many2OneExpandSerializer::class, + 'getter' => 'getAllowedTicketTypes', + ], + ]; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php` around lines 21 - 48, The serializer DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an expand_mappings entry for the inherited allowed_ticket_types relation; add a protected static $expand_mappings property (matching the pattern used in DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps 'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the allowed_ticket_types expansion that returns the ticket types collection using the same serializer used elsewhere for ticket types), so API consumers can expand allowed_ticket_types when calling the serialize method.app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php (1)
122-134: Add PHPDoc documentation for$companyparameter — it's part of the interface contract across all promo code implementations.The
$companyparameter is unused in this method but is retained for consistency with the base class signatureSummitRegistrationPromoCode::checkSubject(), which is polymorphically implemented across all promo code trait variants (Member, Speaker, Speakers, Sponsor, DomainAuthorized). Add a brief PHPDoc note explaining its retention for interface compatibility:/** * `@param` string $email * `@param` null|string $company Retained for interface compatibility; not used in domain-authorized validation * `@return` bool * `@throws` ValidationException */🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php` around lines 122 - 134, Add a PHPDoc block to the DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused $company parameter for interface compatibility; update the docblock to include `@param` string $email, `@param` null|string $company Retained for interface compatibility; not used in domain-authorized validation, `@return` bool and `@throws` ValidationException so the method signature matches the SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is intentionally unused.app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php (1)
51-58: This override doesn't implement the documented audience bypass.Right now it only forwards to
parent::addAllowedTicketType(), so the “regardless of audience value” behavior in the comment is not actually expressed here. Either remove the override/comment or put the intended bypass logic in this method.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php` around lines 51 - 58, The override addAllowedTicketType in DomainAuthorizedSummitRegistrationPromoCode currently just calls parent::addAllowedTicketType and does not implement the claimed "regardless of audience" behavior; either remove this override and its comment, or implement the bypass by directly adding the SummitTicketType to this promo's allowed ticket collection without performing the parent's audience check (e.g., perform the collection add/update inside addAllowedTicketType on DomainAuthorizedSummitRegistrationPromoCode rather than calling parent::addAllowedTicketType). Ensure you update or remove the doc comment to match the actual behavior.tests/oauth2/OAuth2SummitPromoCodesApiTest.php (1)
1085-1116: These skipped checkout tests won't catch regressions.Right now the new quantity-per-account checkout path has no protection from this file because all three enforcement tests are permanently skipped. If the full OAuth/saga harness is too heavy, a narrower test around
ApplyPromoCodeTask::run()would still cover the new guard.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php` around lines 1085 - 1116, The three checkout tests in OAuth2SummitPromoCodesApiTest.php are skipped and leave the new quantity-per-account enforcement untested; add focused unit tests that call ApplyPromoCodeTask::run() directly (instead of the full saga) to cover over-limit, under-limit, and basic concurrency behavior: create tests that construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo repository, order repository/count query, and any lock/persistence layer), stub the member's existing redemptions count to simulate over-limit and under-limit scenarios and assert the task throws the expected rejection or completes successfully, and for concurrency simulate the reservation/count behavior via mocks to assert the pessimistic-lock path; place these tests in OAuth2SummitPromoCodesApiTest.php replacing the skipped methods (testCheckoutRejectsOverLimitQuantityPerAccount, testCheckoutSucceedsUnderLimitQuantityPerAccount, testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly exercised.tests/Unit/Services/DomainAuthorizedPromoCodeTest.php (1)
291-305: Vacuously true assertion reduces test value.Line 304 asserts that id
99is not in the result, but no ticket type with id99was created in this test. The assertion will always pass regardless of the actual filtering behavior.To strengthen this test, either:
- Remove the misleading assertion about id 99 (the test still proves the strategy returns results via the id 30 check), or
- Add a WithPromoCode ticket type to the promo code mock and verify the strategy correctly excludes it when no promo code is provided.
♻️ Suggested fix — remove misleading assertion
$ids = array_map(fn($tt) => $tt->getId(), $result); // Audience_All type IS returned (non-vacuous: proves the strategy produces results) $this->assertContains(30, $ids, 'Audience_All ticket type should be returned without a promo code'); - // WithPromoCode type (id 99) is NOT returned — it only lives in promo_code->getAllowedTicketTypes() - $this->assertNotContains(99, $ids, 'WithPromoCode ticket types should not be returned without a promo code');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php` around lines 291 - 305, The assertion is vacuous because no ticket type with id 99 exists in this test; update the testWithPromoCodeAudienceNoPromoCodeNotReturned to actually create a "WithPromoCode" ticket type (e.g., via buildMockTicketType(99, SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked promo code's getAllowedTicketTypes() so that RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes() has both id 30 and id 99 available in the source data and you can assert that 30 is returned while 99 is not; alternatively, if you prefer the simpler change, remove the misleading assertNotContains(99, $ids) line entirely to avoid the vacuous check.app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php (1)
53-60: Remove this no-op override or clarify intent with accurate documentation.The parent
SummitRegistrationPromoCode::addAllowedTicketType()contains no audience-based restrictions—it simply adds the ticket type to the collection. This override merely delegates to the parent without any custom logic, making it redundant. The docblock's claim that it "allows any ticket type regardless of audience value" is misleading, as the parent already permits any ticket type. Either remove the override or, if it serves a semantic purpose in the inheritance hierarchy, update the docblock to accurately reflect that.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php` around lines 53 - 60, The addAllowedTicketType method in DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode already permits any ticket type); either remove this override entirely to avoid dead code, or if you want an explicit semantic override, keep the method but replace the misleading docblock with a concise note like "Intentional passthrough to parent to signal inherited behavior" and ensure the method body remains a direct parent call (referencing DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and SummitRegistrationPromoCode::addAllowedTicketType).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php`:
- Around line 295-296: Normalize the allowed_email_domains input to an array
before calling the typed setter setAllowedEmailDomains: if
$data['allowed_email_domains'] is a string, split on commas (or another
delimiter used by CSV), trim each entry and remove empty values so you always
pass an array to SummitPromoCodeFactory::setAllowedEmailDomains; apply the same
normalization pattern for the other CSV-driven fields that expect arrays (the
other typed setters in this factory, e.g., setAllowedTicketTypes /
setTicketTypesRules or any similar setter calls around the nearby block) so
string CSV values don't throw and get silently skipped.
- Around line 201-202: Replace uses of boolval(...) for the auto_apply field
with filter_var($data['auto_apply'], FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE) so string values like "false" are parsed correctly;
specifically update every branch that calls
$promo_code->setAutoApply(boolval($data['auto_apply'])) (all occurrences
handling auto_apply in SummitPromoCodeFactory.php) to first compute the boolean
via filter_var and then pass that result to $promo_code->setAutoApply, applying
the same change to each auto_apply branch mentioned (the multiple setAutoApply
call sites).
In
`@app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.php`:
- Line 98: Replace the hardcoded null assignment to
$values['remaining_quantity_per_account'] with the actual value from the
domain-authorized discount model (e.g. call the model's getter such as
getRemainingQuantityPerAccount() on the serialized object or
$domainAuthorizedDiscount/$this->object) and only fall back to null if that
getter returns an unset/empty value; update
MemberSummitRegistrationDiscountCodeSerializer to read and serialize the real
remaining-per-account value instead of forcing null.
In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 46-58: The .tld branch in AllowedEmailDomainsArray (the elseif
that checks str_starts_with($element, '.')) uses a regex that only allows a
single label after the leading dot; update that regex to accept multi-level
suffixes by matching a leading dot followed by one label and then zero or more
additional ".label" segments (i.e., allow repeated dot-separated labels) so
patterns like ".co.uk" or ".ac.jp" are accepted; replace the existing /^\.\w+$/
pattern in that branch accordingly.
In `@database/migrations/model/Version20260401150000.php`:
- Around line 99-117: In the down() migration, before reverting the ClassName
ENUM on SummitRegistrationPromoCode, remove or update any rows that reference
the domain-authorized discriminators so the ALTER TABLE won't fail;
specifically, after dropping the joined tables
DomainAuthorizedSummitRegistrationPromoCode and
DomainAuthorizedSummitRegistrationDiscountCode, delete (or UPDATE ClassName to a
valid value) any SummitRegistrationPromoCode rows whose ClassName equals the
domain-authorized values (e.g., the discriminator values introduced for
domain-authorized promo/discount codes) and only then run the ALTER TABLE ...
MODIFY ClassName ENUM(...) DEFAULT 'SummitRegistrationPromoCode' to narrow the
enum safely.
In `@routes/api_v1.php`:
- Around line 1953-1956: The discover route is currently rate-limited but
unauthenticated; update the route definition for the 'all/discover' endpoint to
include the authentication middleware so only authenticated users can call
OAuth2SummitPromoCodesApiController@discover. Modify the middleware array on the
Route::get for 'discover' to add 'auth.user' alongside 'rate.limit:25,1' (e.g.,
['middleware' => ['auth.user', 'rate.limit:25,1']]) so the route enforces
authentication at the routing layer before controller execution.
---
Outside diff comments:
In `@app/Services/Model/Imp/SummitOrderService.php`:
- Around line 284-296: ReserveOrderTask::run() currently replaces the saga state
(losing keys like promo_codes_usage) so ApplyPromoCodeTask::run() no longer
receives required promo data; fix by preserving/merging state instead of
overwriting (ensure ReserveOrderTask::run() returns array_merge($existingState,
['order' => $order]) or similar), or alternatively move the ApplyPromoCodeTask
invocation to run before ReserveOrderTask so promo redemption happens prior to
persisting the order; also apply the same fix at the other occurrence referenced
(lines ~667-674) and ensure undo() implementations handle partial persistence.
- Around line 728-742: The constructor currently stores a nullable $owner
causing per-account checks (QuantityPerAccount) to be skipped; update the
reserve flow so that before enforcing QuantityPerAccount (in ReserveOrderTask
and the methods around the QuantityPerAccount check referenced at lines
~791-809) you resolve the actual owner from the reserved order (or by
owner_email) instead of relying on the initial $this->owner being non-null;
specifically, locate the QuantityPerAccount enforcement code and change it to
obtain the owner via the reserved Order object (or lookup by owner_email) and
then perform the per-account usage count against that resolved Member before
allowing the reservation to proceed.
---
Nitpick comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`:
- Around line 1585-1602: The OpenAPI annotation for the controller method (the
OA\Get block above discoverPromoCodesBySummit in
OAuth2SummitPromoCodesApiController) is missing the query parameters used in the
implementation; add two OA\Parameter entries for "fields" and "relations" (both
in="query", required=false, schema type="string") with brief descriptions (e.g.,
"Comma-separated list of fields to include" and "Comma-separated list of
relations to expand") so the documented parameters match the code that reads
fields and relations from the request.
In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php`:
- Around line 122-134: Add a PHPDoc block to the
DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused
$company parameter for interface compatibility; update the docblock to include
`@param` string $email, `@param` null|string $company Retained for interface
compatibility; not used in domain-authorized validation, `@return` bool and
`@throws` ValidationException so the method signature matches the
SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is
intentionally unused.
In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php`:
- Around line 53-60: The addAllowedTicketType method in
DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only
calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode
already permits any ticket type); either remove this override entirely to avoid
dead code, or if you want an explicit semantic override, keep the method but
replace the misleading docblock with a concise note like "Intentional
passthrough to parent to signal inherited behavior" and ensure the method body
remains a direct parent call (referencing
DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and
SummitRegistrationPromoCode::addAllowedTicketType).
In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php`:
- Around line 51-58: The override addAllowedTicketType in
DomainAuthorizedSummitRegistrationPromoCode currently just calls
parent::addAllowedTicketType and does not implement the claimed "regardless of
audience" behavior; either remove this override and its comment, or implement
the bypass by directly adding the SummitTicketType to this promo's allowed
ticket collection without performing the parent's audience check (e.g., perform
the collection add/update inside addAllowedTicketType on
DomainAuthorizedSummitRegistrationPromoCode rather than calling
parent::addAllowedTicketType). Ensure you update or remove the doc comment to
match the actual behavior.
In `@app/Models/Foundation/Summit/Summit.php`:
- Around line 5555-5568: The current Summit::canBuyRegistrationTicketByType()
unconditionally returns true for SummitTicketType::Audience_With_Promo_Code
which conflates visibility with purchase authorization; instead remove the
unconditional return and either (a) add a promo-context parameter (e.g.
$promoCodeProvided or $promoContext) to canBuyRegistrationTicketByType and check
promo validity before granting purchase, or (b) split responsibilities by
creating a separate visibility helper (e.g. isTicketTypeVisibleToEmail or
canViewTicketType) that returns true for Audience_With_Promo_Code while leaving
canBuyRegistrationTicketByType to enforce promo-code checks; update callers to
pass the promo context or use the new visibility helper as appropriate.
In
`@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php`:
- Around line 21-48: The serializer
DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an
expand_mappings entry for the inherited allowed_ticket_types relation; add a
protected static $expand_mappings property (matching the pattern used in
DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps
'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the
allowed_ticket_types expansion that returns the ticket types collection using
the same serializer used elsewhere for ticket types), so API consumers can
expand allowed_ticket_types when calling the serialize method.
In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`:
- Around line 668-721: The member and speaker owner-email checks inside
getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a
small private helper (e.g., private function isOwnerEmailMatch($code, string
$email): bool) that reads $code->getOwnerEmail(), lowercases and compares to
$email and checks $code->isLive(), then replace the two separate blocks (the
checks against MemberSummitRegistrationPromoCode,
MemberSummitRegistrationDiscountCode and against
SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with
either a single instanceof-any conditional that calls the helper or two concise
calls to the helper, leaving domain-authorized logic unchanged.
In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 70-73: The message() method in AllowedEmailDomainsArray currently
calls trans() with a literal string; replace it to use a translation key (e.g.
use __('validation.allowed_email_domains_array') or
trans('validation.allowed_email_domains_array')) in the message() method and add
the corresponding key/value in your language files
(resources/lang/{locale}/validation.php) so i18n works correctly; reference:
AllowedEmailDomainsArray::message().
In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php`:
- Around line 1085-1116: The three checkout tests in
OAuth2SummitPromoCodesApiTest.php are skipped and leave the new
quantity-per-account enforcement untested; add focused unit tests that call
ApplyPromoCodeTask::run() directly (instead of the full saga) to cover
over-limit, under-limit, and basic concurrency behavior: create tests that
construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo
repository, order repository/count query, and any lock/persistence layer), stub
the member's existing redemptions count to simulate over-limit and under-limit
scenarios and assert the task throws the expected rejection or completes
successfully, and for concurrency simulate the reservation/count behavior via
mocks to assert the pessimistic-lock path; place these tests in
OAuth2SummitPromoCodesApiTest.php replacing the skipped methods
(testCheckoutRejectsOverLimitQuantityPerAccount,
testCheckoutSucceedsUnderLimitQuantityPerAccount,
testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly
exercised.
In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php`:
- Around line 291-305: The assertion is vacuous because no ticket type with id
99 exists in this test; update the
testWithPromoCodeAudienceNoPromoCodeNotReturned to actually create a
"WithPromoCode" ticket type (e.g., via buildMockTicketType(99,
SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked
promo code's getAllowedTicketTypes() so that
RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes()
has both id 30 and id 99 available in the source data and you can assert that 30
is returned while 99 is not; alternatively, if you prefer the simpler change,
remove the misleading assertNotContains(99, $ids) line entirely to avoid the
vacuous check.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e384ac84-d886-48f3-8deb-ae6c4bca21ae
📒 Files selected for processing (38)
app/Http/Controllers/Apis/Protected/Summit/Factories/Registration/PromoCodesValidationRulesFactory.phpapp/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.phpapp/ModelSerializers/SerializerRegistry.phpapp/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCodeSerializer.phpapp/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.phpapp/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.phpapp/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCodeSerializer.phpapp/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.phpapp/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.phpapp/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.phpapp/Models/Foundation/Summit/Registration/PromoCodes/AutoApplyPromoCodeTrait.phpapp/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.phpapp/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/PromoCodesConstants.phpapp/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.phpapp/Models/Foundation/Summit/Registration/PromoCodes/Strategies/RegularPromoCodeTicketTypesStrategy.phpapp/Models/Foundation/Summit/Registration/PromoCodes/SummitRegistrationPromoCode.phpapp/Models/Foundation/Summit/Registration/SummitTicketType.phpapp/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.phpapp/Models/Foundation/Summit/Summit.phpapp/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.phpapp/Rules/AllowedEmailDomainsArray.phpapp/Services/Model/ISummitPromoCodeService.phpapp/Services/Model/Imp/SummitOrderService.phpapp/Services/Model/Imp/SummitPromoCodeService.phpdatabase/migrations/model/Version20260401150000.phpdatabase/seeders/ApiEndpointsSeeder.phpdoc/promo-codes-for-early-registration-access.mdroutes/api_v1.phptests/Unit/Services/DomainAuthorizedPromoCodeTest.phptests/Unit/Services/PreProcessReservationTaskTest.phptests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.phptests/oauth2/OAuth2SummitPromoCodesApiTest.php
… migration rollback CodeRabbit flagged 6 issues on PR #525. After independent validation (Codex), 2 were confirmed as real bugs, 2 were false positives, and 2 were informational/misframed. Fixed (validated as real): - **CSV import TypeError:** `allowed_email_domains` was not exploded from its pipe-delimited CSV string before reaching `setAllowedEmailDomains(array)`, causing a TypeError on domain-authorized code import. Added the same `explode('|', ...)` normalization used by all other CSV list fields in both the add and update import paths. - **Migration down() failure:** Dropping the joined domain-authorized tables did not remove orphaned base-table rows, so narrowing the ClassName ENUM would fail if any domain-authorized promo codes existed. Added a DELETE statement before the ALTER TABLE. Dismissed (validated as false positives): - `remaining_quantity_per_account = null` in MemberDiscountCode serializer is correct — Member types do not have per-account quantity. - Discover route already has OAuth2 auth via the `api` middleware group and an explicit controller-level null-member guard. Adding `auth.user` would break it (requires authz_groups, intentionally removed in 138c1f8). Deferred: - `boolval("false")` pattern is pre-existing across the factory (not introduced by this PR); warrants a separate cleanup. - Multi-level TLD validation regex (`.co.uk`) is an enhancement, not a bug in the current domain-matching logic. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
app/Services/Model/Imp/SummitPromoCodeService.php (1)
1031-1054: Batch per-code usage lookups for discovery.
getDiscoverableByEmailForSummit()loads the candidate codes, and then each domain-authorized result triggers anothergetTicketCountByMemberAndPromoCode()call. On a summit with many matching codes, this becomes a 1+N query pattern on the registration hot path. Consider a repository method that returns usage counts keyed by promo-code ID for the member.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Services/Model/Imp/SummitPromoCodeService.php` around lines 1031 - 1054, The loop in SummitPromoCodeService uses getDiscoverableByEmailForSummit() then calls getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a repository method such as getTicketCountsByMemberAndPromoCodes($member, array $promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after retrieving $codes, then in the foreach over $codes (same loop referencing IDomainAuthorizedPromoCode, getQuantityPerAccount, setRemainingQuantityPerAccount) read the used count from that map to decide exhaustion and set remaining per-account quantity instead of making per-code repository calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 645-647: The CSV import currently uses isset() then explode('|',
$row['allowed_email_domains']) which yields [''] for blank cells and leaves
whitespace—update both occurrences handling allowed_email_domains in
SummitPromoCodeService.php (the two blocks around the shown diff and the block
at the other path mentioned) to: after exploding by '|' trim each element and
remove any empty strings (e.g., array_map('trim', ...) followed by
array_filter(...) or equivalent) so the resulting $row['allowed_email_domains']
is an array of non-empty, trimmed domains or omitted if none remain.
In `@database/migrations/model/Version20260401150000.php`:
- Around line 103-107: Replace the hard DELETE that removes base-table rows from
SummitRegistrationPromoCode with a safe UPDATE that remaps the discriminator to
the base ClassName so rows (and their IDs/foreign links) are preserved;
specifically modify the SQL in Version20260401150000.php that targets ClassName
IN
('DomainAuthorizedSummitRegistrationDiscountCode','DomainAuthorizedSummitRegistrationPromoCode')
to run an UPDATE setting ClassName = 'SummitRegistrationPromoCode' (or the
appropriate surviving base type) instead of DELETE, ensuring child table cascade
behavior and external FK integrity remain intact.
---
Nitpick comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 1031-1054: The loop in SummitPromoCodeService uses
getDiscoverableByEmailForSummit() then calls
getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a
repository method such as getTicketCountsByMemberAndPromoCodes($member, array
$promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after
retrieving $codes, then in the foreach over $codes (same loop referencing
IDomainAuthorizedPromoCode, getQuantityPerAccount,
setRemainingQuantityPerAccount) read the used count from that map to decide
exhaustion and set remaining per-account quantity instead of making per-code
repository calls.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 629c3bf1-4fa4-415f-bf98-f0555ef818a6
📒 Files selected for processing (2)
app/Services/Model/Imp/SummitPromoCodeService.phpdatabase/migrations/model/Version20260401150000.php
| public function up(Schema $schema): void | ||
| { | ||
| // 1. Create DomainAuthorizedSummitRegistrationDiscountCode joined table | ||
| $this->addSql("CREATE TABLE DomainAuthorizedSummitRegistrationDiscountCode ( |
There was a problem hiding this comment.
@caseylocker dont use this there are already utils to create table please review used patterns
There was a problem hiding this comment.
Rewrote in b9f2582. Table creation and ADD COLUMN now use the LaravelDoctrine Builder + Table API (matching Version20231208171355 and Version20260407003923). ENUM MODIFY remains raw addSql because Doctrine Schema has no MySQL ENUM support — same pattern as Version20231208172204. Charset aligned with existing promo-code subtype tables (utf8mb3_unicode_ci). Verified with a full down-up-down-up round-trip on the model DB.
| public function down(Schema $schema): void | ||
| { | ||
| // 1. Drop AutoApply columns from existing email-linked subtype tables | ||
| $this->addSql("ALTER TABLE SpeakerSummitRegistrationDiscountCode DROP COLUMN AutoApply"); |
There was a problem hiding this comment.
@caseylocker
Migration down() Ordering issue
MySQL DDL (DROP TABLE) causes an implicit commit. If the migration fails between step 4 (DROP TABLE) and step 4b (UPDATE ClassName), the joined tables are gone but the
discriminator column still points to them. Doctrine will attempt to join non-existent tables for any row with ClassName DomainAuthorized*, crashing all promo code queries for the entire summit.
Suggested fix:
Swap steps 4 and 4b , remap ClassName to base types first, then drop the joined tables.
There was a problem hiding this comment.
Fixed in b9f2582 (same commit as the table-util refactor). The new down() sequence runs the ClassName remap BEFORE the joined-table drops so a mid-rollback failure can't leave the discriminator pointing at dropped tables.
| * @param Member $member | ||
| * @return SummitRegistrationPromoCode[] | ||
| */ | ||
| public function discoverPromoCodes(Summit $summit, Member $member): array |
There was a problem hiding this comment.
@caseylocker getDiscoverableByEmailForSummit loads all discoverable promo codes into memory
The DQL query fetches every promo code of 6 types for the entire summit, then filters in PHP by domain/email. For summits with thousands of codes, this is a full table scan hydrated into entities.
If you want to optimize, the cleanest approach is splitting into two queries:
- Domain-authorized codes — fetch all for summit, filter in PHP (unavoidable)
- Email-linked codes — filter by email in DQL (eliminates the bulk of hydrations)
There was a problem hiding this comment.
Split exactly as you suggested in 4db6055 + 999caa1. getDiscoverableByEmailForSummit now delegates to two focused methods:
- getDomainAuthorizedDiscoverableForSummit — fetches only the 2 DA types for the summit, filters by matchesEmailDomain() in PHP (unavoidable pattern match).
- getEmailLinkedDiscoverableForSummit — 4 DQL queries (one per Member/Speaker x Promo/Discount subtype) push the email filter into the WHERE clause, including the MemberPromoCodeTrait owner-fallback (IS NULL OR empty-string) and the PresentationSpeaker member/registration_request two-hop chain.
Both methods add isLive() date filtering in DQL (:now parameter). The facade method merges results, preserving the existing caller contract — discoverPromoCodes and its exhaustion/quota logic are untouched.
| */ | ||
| public function passes($attribute, $value): bool | ||
| { | ||
| if (!is_array($value)) |
There was a problem hiding this comment.
@caseylocker
AllowedEmailDomainsArray validation rejects multi-level TLD suffix patterns
The .tld suffix regex /^.\w+$/ rejects .co.uk, .com.au, .ac.uk — common patterns for UK/AU/international organizations. Admins can't create these patterns via the API, even
though matchesEmailDomain() handles them correctly at runtime.
There was a problem hiding this comment.
Widened in c62c0f7. The regex now matches one-or-more dot-prefixed alphanumeric labels (/^.[a-z0-9]+(?:.[a-z0-9]+)*$/i), so admins can register .co.uk, .com.au, .ac.uk via the API. Still rejects malformed inputs (., ..com, .com., .co..uk, .-edu). Runtime matcher already accepted these — the validator was incorrectly narrower.
| * - @domain.com (exact domain match, starts with @) | ||
| * - .tld (TLD/suffix match, starts with .) | ||
| * - [email protected] (exact email match) | ||
| */ |
There was a problem hiding this comment.
@caseylocker
AllowedEmailDomainsArray validation rule — no tests for edge cases like .co.uk, empty array, mixed valid/invalid patterns
There was a problem hiding this comment.
Added tests/Unit/Rules/AllowedEmailDomainsArrayTest.php in c62c0f7. 31 cases cover the regression fix (.co.uk, .com.au, .ac.uk), valid/invalid pattern mixes, empty array, non-array inputs, whitespace handling, and message(). Uses doc-comment @dataProvider to match the one existing precedent in the repo.
| }); | ||
| } | ||
|
|
||
| public function undo() |
There was a problem hiding this comment.
@caseylocker
ReserveOrderTask::undo() compensation , the new implementation detaches tickets and deletes the order but has no dedicated test verifying the cleanup behavior after ApplyPromoCodeTask failure
There was a problem hiding this comment.
Covered by tests/Unit/Services/SagaCompensationTest.php::testUndoDeletesOrderAndDetachesTicketsFromAttendees (L100). Asserts the order is deleted and each ticket is detached from its attendee owner after a saga abort.
smarcet
left a comment
There was a problem hiding this comment.
@caseylocker please review comments
Addresses smarcet's PR #525 review: - Replace raw CREATE TABLE / ADD COLUMN with LaravelDoctrine Builder + Table fluent API (matches Version20231208171355, Version20260407003923). - Reorder down(): remap ClassName discriminator before dropping joined tables so an interrupted rollback can't leave the enum pointing at non-existent tables (DROP TABLE implicit-commit hazard). - Keep ENUM MODIFY as raw addSql (Doctrine Schema has no MySQL ENUM support; matches Version20231208172204). - Verified with down/up round-trip on local model DB; Codex audit clean.
Addresses smarcet's PR #525 review items #8 and #9: - Widen the .tld branch regex from /^\.\w+$/ to /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i so admins can register suffixes like .co.uk, .com.au, .ac.uk via the API. The runtime matcher in DomainAuthorizedPromoCodeTrait::matchesEmailDomain already supported these via str_ends_with — the validator was incorrectly narrower than the runtime. - Add tests/Unit/Rules/AllowedEmailDomainsArrayTest covering 31 cases: valid single/multi-label TLDs, @Domain, exact email, uppercase, trimmed whitespace, mixed valid; plus rejection of non-arrays, empty and whitespace-only entries, non-string elements, nested arrays, and malformed patterns (., ..com, .com., .co..uk, .-edu, @, @.com, bare tokens, one-bad-apple arrays). Uses @dataProvider doc-comment style to match the existing precedent (AbstractOAuth2ApiScopesTest).
…layer) Schema + data layer for the per-member QuantityPerAccount counter that will fix the TOCTOU race smarcet flagged in PR #525 (and reproduced in PR #530). This commit is intentionally NOT wired into the order-reserve saga yet — the table sits unused until the follow-up commit that modifies PreProcessReservationTask. - Entity SummitPromoCodeMemberReservation (SilverstripeBaseModel) with unique (PromoCodeID, MemberID) and ManyToOne FKs cascading on delete. - ISummitPromoCodeMemberReservationRepository + Doctrine impl. Readers from the reservation path rely on the outer row lock already held on the parent promo code (getByValueExclusiveLock) for serialization, so the repo does not take its own PESSIMISTIC_WRITE. - Two migrations, split so CREATE TABLE commits before the backfill INSERT runs (Builder defers schema diff to end-of-migration, so INSERT-in-same-migration hits "table doesn't exist"): Version20260415191521 — create table via Builder/Table API. Version20260415191522 — backfill from existing committed tickets (ON DUPLICATE KEY UPDATE for idempotency). - RepositoriesProvider binding. - Verified: down/up round-trip on docker MySQL; php -l clean.
Three no-behavior-change safety nits from the step-1 audit: - Version20260415191522 backfill now uses GREATEST(QtyUsed, VALUES(QtyUsed)) on the ON DUPLICATE KEY path so a re-run after live saga writes can never clobber a newer counter with a stale historical count. - Version20260415191522.down() is now a documented no-op. The previous TRUNCATE would have silently zeroed live counters once step 2 lands. Roll back Version20260415191521 if a clean slate is needed. - ISummitPromoCodeMemberReservationRepository docblock is reworded so the 'outer lock' statement is explicit as a CALLER PRECONDITION, not something the repository guarantees or enforces. Migration down/up round-trip re-verified on docker MySQL. Remaining step-1 concern — undo idempotency of the entity's decrement helper — is deferred to step 2, where the saga caller is the right place to guard duplicate undo invocations (mirror the 'redeem' flag pattern already used by ApplyPromoCodeTask::undo).
…Task Step 2 of 3 for the TOCTOU fix. Wires SummitPromoCodeMemberReservation (added in b1f3abe) into the order-reserve saga. The post-facto check in ApplyPromoCodeTask stays in place as belt-and-suspenders for this commit; it is removed in step 3. - PreProcessReservationTask gains two optional collaborators (ISummitPromoCodeMemberReservationRepository, ITransactionService) plus a new protected reserveMemberQuotas() pass that runs after the existing validation. For each IDomainAuthorizedPromoCode in the payload with QuantityPerAccount > 0, it opens a short transaction that acquires PESSIMISTIC_WRITE on the parent promo code row (via the existing getByValueExclusiveLock), upserts the per-member counter, and rejects if the new total would exceed the limit. - The outer row lock is the serialization point: two concurrent sagas serialize on the lock, and the second observes the first's increment once the first commits. - undo() walks the reserved list and decrements each counter under the same lock. Idempotent via a local $undone flag; best-effort (logs and continues on failure so remaining codes still release). - SagaFactory and SummitOrderService ctors are extended to carry the new repo through from Laravel's container; PreProcessReservationTask ctor params stay nullable-optional so the existing 2-arg construction path in tests and the PrePaid subclass keeps working. Verified: docker exec summit-api vendor/bin/phpunit --filter "PreProcessReservationTaskTest|SagaCompensationTest|ApplyPromoCodeTaskQuantityPerAccountTest" → 13/13 pass (baseline backward compat) php -l clean. Step 3 (follow-up) will remove the post-facto check in ApplyPromoCodeTask, update smarcet's PR #530 test mocks to exercise the new reservation surface, and add dedicated coverage for the pre-reservation path.
Codex audit of ad113d5 caught a real BUG. Saga::run() at SummitOrderService.php:131-134 only calls markAsRan() AFTER a task's run() returns. If reserveMemberQuotas() succeeds for code A and then throws on code B, the exception propagates before PreProcessReservationTask is in $already_run_tasks, so saga abort() never invokes this task's undo() — leaking code A's counter increment on the durable reservation row. Guard reserveMemberQuotas() with a local try/catch in run() that calls $this->undo() (idempotent via the $undone flag) before rethrowing, so any partial progress is released whether or not the saga reaches us. Found by Codex review, patch as proposed.
The output is .E. — tests 1 and 3 pass, test 2
(testDoubleRejection_BothReservedBeforeEitherValidates) fails with:
ValidationException: Promo code DOMAIN-CODE-1 has reached the maximum of 1 tickets per account.
Task A throws at SummitOrderService.php:843 (the $existingCount > $quantityPerAccount guard)
(exactly the TOCTOU bug).
The test asserts Task A should succeed (it's a valid first request), but the inflated count
from both orders' tickets being visible causes it to reject.
When the race condition is fixed, this test will start passing.
…ests Step 3 of 3. The per-member QuantityPerAccount check now lives solely in PreProcessReservationTask::reserveMemberQuotas (added in ad113d5, leak-guarded in 77c3059), where it runs atomically under the PESSIMISTIC_WRITE row lock on the parent promo code, BEFORE ReserveTicketsTask and ReserveOrderTask commit any tickets. Changes: - Remove the belt-and-suspenders post-facto check from ApplyPromoCodeTask::run. A comment now points at the pre-reservation location and links to smarcet's TOCTOU reproduction for context. The old check counted committed tickets and could not distinguish concurrent orders' rows — see the race narrative in PR #525. - Delete tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest.php. smarcet added this in the cherry-picked 2e0ef84 to prove the TOCTOU bug against the old check surface (static getTicketCountByMemberAndPromoCode). That surface is no longer in the write path, so the test targets removed code. The narrative is preserved — and extended — in the new file below. - Delete tests/Unit/Services/ApplyPromoCodeTaskQuantityPerAccountTest.php. All six cases validated the exact post-facto check that was just removed. - Add tests/Unit/Services/PreProcessReservationTaskConcurrencyTest.php with 6 cases exercising the new surface via reflection on reserveMemberQuotas: 1. First reserve succeeds when no prior row exists (repo `add` called with qty_used=1). 2. Second reserve rejects when the prior row's QtyUsed already sits at the limit (the serialized-second-request flow that replaces smarcet's TOCTOU reproduction). 3. Within-limit reserve increments the existing row. 4. Limit = 0 bypasses reservation entirely (unlimited per account). 5. Non-IDomainAuthorizedPromoCode codes are skipped (no reservation repo calls). 6. undo() decrements each reserved counter exactly once and is idempotent via the $undone guard. Verified: docker exec summit-api composer dump-autoload # pick up new entity docker exec summit-api vendor/bin/phpunit --filter "PreProcessReservationTask|SagaCompensationTest|ApplyPromoCodeTask" → 13/13 pass (3 PHPUnit deprecations match repo baseline). Outstanding from smarcet's PR #525 review: #7 (discoverPromoCodes N+1) is the only remaining item.
Two findings from Codex audit of 999eec1: 1. BUG — ApplyPromoCodeTask's pointer comment referenced tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest, a file deleted in the same commit. Repoint at the new location, tests/Unit/Services/PreProcessReservationTaskConcurrencyTest. (Patch applied by Codex.) 2. CONCERN — coverage parity lost the "single order qty > limit with no prior reservation row" case from the deleted ApplyPromoCodeTaskQuantityPerAccountTest::testRejectsWhenOrderExceedsLimit. Restore that branch via a new PreProcessReservation test: testSingleOrderExceedingLimitRejects (limit=1, prior=null, qty=2 → reject; repo `add` must never be called). Now 7/7 passing in PreProcessReservationTaskConcurrencyTest.
… DQL getDiscoverableByEmailForSummit loaded ALL 6 discoverable subtypes for the entire summit, then filtered in PHP — O(all codes) hydrations. Split into two focused methods: - getDomainAuthorizedDiscoverableForSummit: fetches only the 2 DA types, filters by email domain in PHP (unavoidable pattern match) - getEmailLinkedDiscoverableForSummit: 4 DQL queries (one per Member/ Speaker × Promo/Discount subtype) push the email filter into the WHERE clause, including the MemberPromoCodeTrait owner-fallback and the PresentationSpeaker member/registration_request two-hop chain Both methods add isLive() date filtering in DQL (:now parameter), matching the codebase convention from DoctrineSummitRepository. The facade method delegates to both and merges, preserving the existing caller contract (discoverPromoCodes and its exhaustion/quota logic are untouched). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Switch INSTANCE OF from :param binding to inline class interpolation, matching the original code's pattern and avoiding Doctrine discriminator binding edge cases - Add explicit parentheses around isLive DQL condition for defensive clarity (andWhere already wraps, but now consistent with email-linked) - Fix member email fallback: PHP empty() matches both NULL and '', but DQL only checked IS NULL — now also checks e.email = '' to match MemberPromoCodeTrait::getEmail() exactly Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
… retry Two bugs found by Codex review of the TOCTOU fix: 1. restoreTicketsPromoCodes (cancel/refund path) called removeUsage() on the promo code but never decremented the per-member reservation counter (SummitPromoCodeMemberReservation.QtyUsed). After a legitimate cancellation, discovery showed quota available but checkout rejected on the stale counter. Fix: add ?Member $owner parameter, decrement the reservation row after successful removeUsage() in the same try block. Non-ValidationException errors from the reservation path propagate and roll back the transaction. All 4 call sites updated. 2. PreProcessReservationTask::undo() set $undone=true before the loop, making partial failures unrecoverable — failed entries were cleared from $reserved unconditionally. Fix: build a $remaining list of failed entries, set $reserved=$remaining and $undone=empty($remaining) so a retry re-processes only the failed codes. New test file RestorePathReservationTest (6 cases) exercises the real restoreTicketsPromoCodes path via reflection: successful decrement, guest order skip, missing reservation row skip, over-decrement clamp, and non-ValidationException propagation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Addresses smarcet's PR #525 review: - Replace raw CREATE TABLE / ADD COLUMN with LaravelDoctrine Builder + Table fluent API (matches Version20231208171355, Version20260407003923). - Reorder down(): remap ClassName discriminator before dropping joined tables so an interrupted rollback can't leave the enum pointing at non-existent tables (DROP TABLE implicit-commit hazard). - Keep ENUM MODIFY as raw addSql (Doctrine Schema has no MySQL ENUM support; matches Version20231208172204). - Verified with down/up round-trip on local model DB; Codex audit clean.
Addresses smarcet's PR #525 review items #8 and #9: - Widen the .tld branch regex from /^\.\w+$/ to /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i so admins can register suffixes like .co.uk, .com.au, .ac.uk via the API. The runtime matcher in DomainAuthorizedPromoCodeTrait::matchesEmailDomain already supported these via str_ends_with — the validator was incorrectly narrower than the runtime. - Add tests/Unit/Rules/AllowedEmailDomainsArrayTest covering 31 cases: valid single/multi-label TLDs, @Domain, exact email, uppercase, trimmed whitespace, mixed valid; plus rejection of non-arrays, empty and whitespace-only entries, non-string elements, nested arrays, and malformed patterns (., ..com, .com., .co..uk, .-edu, @, @.com, bare tokens, one-bad-apple arrays). Uses @dataProvider doc-comment style to match the existing precedent (AbstractOAuth2ApiScopesTest).
…layer) Schema + data layer for the per-member QuantityPerAccount counter that will fix the TOCTOU race smarcet flagged in PR #525 (and reproduced in PR #530). This commit is intentionally NOT wired into the order-reserve saga yet — the table sits unused until the follow-up commit that modifies PreProcessReservationTask. - Entity SummitPromoCodeMemberReservation (SilverstripeBaseModel) with unique (PromoCodeID, MemberID) and ManyToOne FKs cascading on delete. - ISummitPromoCodeMemberReservationRepository + Doctrine impl. Readers from the reservation path rely on the outer row lock already held on the parent promo code (getByValueExclusiveLock) for serialization, so the repo does not take its own PESSIMISTIC_WRITE. - Two migrations, split so CREATE TABLE commits before the backfill INSERT runs (Builder defers schema diff to end-of-migration, so INSERT-in-same-migration hits "table doesn't exist"): Version20260415191521 — create table via Builder/Table API. Version20260415191522 — backfill from existing committed tickets (ON DUPLICATE KEY UPDATE for idempotency). - RepositoriesProvider binding. - Verified: down/up round-trip on docker MySQL; php -l clean.
…ests Step 3 of 3. The per-member QuantityPerAccount check now lives solely in PreProcessReservationTask::reserveMemberQuotas (added in ad113d5, leak-guarded in 77c3059), where it runs atomically under the PESSIMISTIC_WRITE row lock on the parent promo code, BEFORE ReserveTicketsTask and ReserveOrderTask commit any tickets. Changes: - Remove the belt-and-suspenders post-facto check from ApplyPromoCodeTask::run. A comment now points at the pre-reservation location and links to smarcet's TOCTOU reproduction for context. The old check counted committed tickets and could not distinguish concurrent orders' rows — see the race narrative in PR #525. - Delete tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest.php. smarcet added this in the cherry-picked 2e0ef84 to prove the TOCTOU bug against the old check surface (static getTicketCountByMemberAndPromoCode). That surface is no longer in the write path, so the test targets removed code. The narrative is preserved — and extended — in the new file below. - Delete tests/Unit/Services/ApplyPromoCodeTaskQuantityPerAccountTest.php. All six cases validated the exact post-facto check that was just removed. - Add tests/Unit/Services/PreProcessReservationTaskConcurrencyTest.php with 6 cases exercising the new surface via reflection on reserveMemberQuotas: 1. First reserve succeeds when no prior row exists (repo `add` called with qty_used=1). 2. Second reserve rejects when the prior row's QtyUsed already sits at the limit (the serialized-second-request flow that replaces smarcet's TOCTOU reproduction). 3. Within-limit reserve increments the existing row. 4. Limit = 0 bypasses reservation entirely (unlimited per account). 5. Non-IDomainAuthorizedPromoCode codes are skipped (no reservation repo calls). 6. undo() decrements each reserved counter exactly once and is idempotent via the $undone guard. Verified: docker exec summit-api composer dump-autoload # pick up new entity docker exec summit-api vendor/bin/phpunit --filter "PreProcessReservationTask|SagaCompensationTest|ApplyPromoCodeTask" → 13/13 pass (3 PHPUnit deprecations match repo baseline). Outstanding from smarcet's PR #525 review: #7 (discoverPromoCodes N+1) is the only remaining item.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
Review Response — All 10 Items AddressedThis push (12 commits, Migration & Pattern Fixes
TOCTOU Fix (the big one)
The TOCTOU fix replaces the unfixable post-facto count with a durable per-member reservation counter ( Discover Query Optimization
Split into domain-authorized (PHP filter, unavoidable) + 4 targeted DQL queries per email-linked subtype, pushing email matching and Validation Fix
Test Summary55 unit tests pass (49 existing + 6 new restore-path tests). The 6 OAuth2 discover integration tests have pre-existing failures ( |
The SummitOrderService constructor gained an ISummitPromoCodeMemberReservationRepository parameter in b1f3abe but this test was not updated. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
d01bbc0 to
a1bd4ea
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/ This page is automatically updated on each push to this PR. |
ref: https://app.clickup.com/t/86b952pgc
Summary
DomainAuthorizedSummitRegistrationDiscountCode(with discount) andDomainAuthorizedSummitRegistrationPromoCode(access-only)WithPromoCodeaudience value onSummitTicketTypefor promo-code-only ticket typesGET /api/v1/summits/{id}/promo-codes/all/discover) that finds matching codes for the authenticated user's emailauto_applysupport to domain-authorized types and existing email-linked types (Member/Speaker)QuantityPerAccountenforcement at both discovery and checkout timeSDS Implementation (12 tasks)
All 12 tasks implemented. All review follow-ups resolved. Two open deviations remain:
allowed_email_domainsvalidation needs custom rule (currentlysometimes|json)QuantityPerAccountcheckout enforcement —ApplyPromoCodeTaskneeds to move afterReserveOrderTaskin saga chainFiles changed (35 files, +3127/-53)
DomainAuthorizedSummitRegistrationDiscountCode,DomainAuthorizedSummitRegistrationPromoCodeDomainAuthorizedPromoCodeTrait,AutoApplyPromoCodeTraitIDomainAuthorizedPromoCodeVersion20260401150000ApplyPromoCodeTaskDomainAuthorizedPromoCodeTest(30 tests)OAuth2SummitPromoCodesApiTestTest plan
php artisan test --filter=DomainAuthorizedPromoCodeTest— all unit tests passphp artisan test --filter="OAuth2SummitPromoCodesApiTest::testDiscover"— discovery integration tests passupanddownrun cleanly🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests