Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
24f1e3d
feat(promo-codes): implement domain-authorized promo codes for early …
caseylocker Apr 8, 2026
05c889c
fix(promo-codes): address review follow-ups for Tasks 1–3
caseylocker Apr 8, 2026
a556412
docs(promo-codes): add Task 4 review follow-up note for no-op override
caseylocker Apr 8, 2026
fe32435
docs(promo-codes): add review follow-ups for Tasks 5 and 7
caseylocker Apr 9, 2026
6a12e47
fix(promo-codes): address Task 6 review follow-ups
caseylocker Apr 9, 2026
2967746
fix(promo-codes): address Task 7 review follow-ups
caseylocker Apr 9, 2026
5dd1ad7
fix(promo-codes): address review follow-ups for Tasks 8 and 9
caseylocker Apr 9, 2026
82a28c3
fix(promo-codes): address Task 10 review follow-ups — race-safe quant…
caseylocker Apr 9, 2026
a5809af
docs(promo-codes): add Task 11 review follow-up notes
caseylocker Apr 9, 2026
b38e434
fix(promo-codes): address Task 12 review follow-ups — tests for colli…
caseylocker Apr 9, 2026
a9ece25
fix(promo-codes): register discover endpoint in ApiEndpointsSeeder
caseylocker Apr 9, 2026
138c1f8
fix(promo-codes): use rate.limit instead of auth.user on discover route
caseylocker Apr 9, 2026
b87cefd
fix(promo-codes): guard WithPromoCode reservations and exclude exhaus…
caseylocker Apr 9, 2026
ed2064d
test(promo-codes): add mixed-payload and infinite-code regression tests
caseylocker Apr 9, 2026
19e5f53
fix(promo-codes): fix serializer tests and resolve D3 deviation
caseylocker Apr 10, 2026
ae261a7
fix(promo-codes): address CodeRabbit findings — CSV domain import and…
caseylocker Apr 10, 2026
c3f8df7
fix(promo-codes): harden CSV domain import and migration rollback safety
caseylocker Apr 10, 2026
c2719e1
docs(promo-codes): add D10/D11 deviations for CSV import and migratio…
caseylocker Apr 10, 2026
c4bcdef
fix(promo-codes): address smarcet review — saga compensation, discove…
caseylocker Apr 13, 2026
e2ca6b5
Merge remote-tracking branch 'origin/main' into pr-525
caseylocker Apr 13, 2026
d824974
test(saga): clear resolved facade instances in setUp for test isolation
caseylocker Apr 13, 2026
93bc180
fix(promo-codes): address CodeRabbit findings on saga reorder
caseylocker Apr 13, 2026
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 @@ -20,6 +20,9 @@
use models\summit\SpeakersSummitRegistrationPromoCode;
use models\summit\SpeakerSummitRegistrationDiscountCode;
use models\summit\SpeakerSummitRegistrationPromoCode;
use models\summit\DomainAuthorizedSummitRegistrationDiscountCode;
use models\summit\DomainAuthorizedSummitRegistrationPromoCode;
use App\Rules\AllowedEmailDomainsArray;
use models\summit\SponsorSummitRegistrationDiscountCode;
use models\summit\SponsorSummitRegistrationPromoCode;
/**
Expand Down Expand Up @@ -72,19 +75,21 @@ public static function buildForAdd(array $payload = []): array
switch ($class_name){
case MemberSummitRegistrationPromoCode::ClassName:{
$specific_rules = [
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer'
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'auto_apply' => 'sometimes|boolean',
];
}
break;
case SpeakerSummitRegistrationPromoCode::ClassName:
{
$specific_rules = [
'type' => 'required|string|in:'.join(",", PromoCodesConstants::SpeakerSummitRegistrationPromoCodeTypes),
'speaker_id' => 'sometimes|integer'
'speaker_id' => 'sometimes|integer',
'auto_apply' => 'sometimes|boolean',
];
}
break;
Expand All @@ -106,11 +111,12 @@ public static function buildForAdd(array $payload = []): array
case MemberSummitRegistrationDiscountCode::ClassName:
{
$specific_rules = array_merge([
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
Expand All @@ -119,6 +125,7 @@ public static function buildForAdd(array $payload = []): array
$specific_rules = array_merge([
'type' => 'required|string|in:'.join(",", PromoCodesConstants::SpeakerSummitRegistrationPromoCodeTypes),
'speaker_id' => 'sometimes|integer',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
Expand All @@ -138,6 +145,24 @@ public static function buildForAdd(array $payload = []): array

}
break;
case DomainAuthorizedSummitRegistrationDiscountCode::ClassName:
{
$specific_rules = array_merge([
'allowed_email_domains' => ['sometimes', new AllowedEmailDomainsArray()],
'quantity_per_account' => 'sometimes|integer|min:0',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
case DomainAuthorizedSummitRegistrationPromoCode::ClassName:
{
$specific_rules = [
'allowed_email_domains' => ['sometimes', new AllowedEmailDomainsArray()],
'quantity_per_account' => 'sometimes|integer|min:0',
'auto_apply' => 'sometimes|boolean',
];
}
break;
}

return array_merge($base_rules, $specific_rules);
Expand Down Expand Up @@ -188,19 +213,21 @@ public static function buildForUpdate(array $payload = []): array
switch ($class_name){
case MemberSummitRegistrationPromoCode::ClassName:{
$specific_rules = [
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer'
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'auto_apply' => 'sometimes|boolean',
];
}
break;
case SpeakerSummitRegistrationPromoCode::ClassName:
{
$specific_rules = [
'type' => 'required|string|in:'.join(",", PromoCodesConstants::SpeakerSummitRegistrationPromoCodeTypes),
'speaker_id' => 'sometimes|integer'
'speaker_id' => 'sometimes|integer',
'auto_apply' => 'sometimes|boolean',
];
}
break;
Expand All @@ -222,11 +249,12 @@ public static function buildForUpdate(array $payload = []): array
case MemberSummitRegistrationDiscountCode::ClassName:
{
$specific_rules = array_merge([
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'first_name' => 'required_without:owner_id|string',
'last_name' => 'required_without:owner_id|string',
'email' => 'required_without:owner_id|email|max:254',
'type' => 'required|string|in:'.join(",", PromoCodesConstants::MemberSummitRegistrationPromoCodeTypes),
'owner_id' => 'required_without:first_name,last_name,email|integer',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
Expand All @@ -235,6 +263,7 @@ public static function buildForUpdate(array $payload = []): array
$specific_rules = array_merge([
'type' => 'required|string|in:'.join(",", PromoCodesConstants::SpeakerSummitRegistrationPromoCodeTypes),
'speaker_id' => 'sometimes|integer',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
Expand All @@ -254,6 +283,24 @@ public static function buildForUpdate(array $payload = []): array

}
break;
case DomainAuthorizedSummitRegistrationDiscountCode::ClassName:
{
$specific_rules = array_merge([
'allowed_email_domains' => ['sometimes', new AllowedEmailDomainsArray()],
'quantity_per_account' => 'sometimes|integer|min:0',
'auto_apply' => 'sometimes|boolean',
], $discount_code_rules);
}
break;
case DomainAuthorizedSummitRegistrationPromoCode::ClassName:
{
$specific_rules = [
'allowed_email_domains' => ['sometimes', new AllowedEmailDomainsArray()],
'quantity_per_account' => 'sometimes|integer|min:0',
'auto_apply' => 'sometimes|boolean',
];
}
break;
}

return array_merge($base_rules, $specific_rules);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1575,4 +1575,63 @@ public function sendSponsorPromoCodes($summit_id)
return $this->ok();
});
}

/**
* Discover qualifying promo codes for the current user.
* Returns domain-authorized codes (matched by email domain) and existing email-linked
* codes (member/speaker, matched by associated email) with auto_apply flag.
* Email is always derived from the authenticated principal — no email query parameter accepted.
*/
#[OA\Get(
path: "/api/v1/summits/{id}/promo-codes/all/discover",
summary: "Discover qualifying promo codes for the current user",
description: "Returns domain-authorized promo codes (matched by email domain) and existing email-linked promo codes (member/speaker, matched by associated email) for the current user",
operationId: "discoverPromoCodesBySummit",
tags: ["Promo Codes"],
security: [['summit_promo_codes_oauth2' => [SummitScopes::ReadSummitData, SummitScopes::ReadAllSummitData]]],
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")),
],
responses: [
new OA\Response(response: Response::HTTP_OK, description: "OK"),
new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"),
new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden"),
new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Summit not found"),
]
)]
public function discover($summit_id)
{
return $this->processRequest(function () use ($summit_id) {

$summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->resource_server_context)->find(intval($summit_id));
if (is_null($summit))
return $this->error404();

$current_member = $this->resource_server_context->getCurrentUser();
if (is_null($current_member))
return $this->error403();

$codes = $this->promo_code_service->discoverPromoCodes($summit, $current_member);

$expand = SerializerUtils::getExpand();
$fields = SerializerUtils::getFields();
$relations = SerializerUtils::getRelations();

$data = [];
foreach ($codes as $code) {
$serializer = SerializerRegistry::getInstance()->getSerializer($code);
$data[] = $serializer->serialize($expand, $fields, $relations);
}

$total = count($data);
return $this->ok([
'total' => $total,
'per_page' => $total,
'current_page' => 1,
'last_page' => 1,
'data' => $data,
]);
});
}
}
12 changes: 12 additions & 0 deletions app/ModelSerializers/SerializerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,18 @@ private function __construct()
self::SerializerType_PreValidation => SummitRegistrationPromoCodePreValidationSerializer::class,
];

$this->registry['DomainAuthorizedSummitRegistrationDiscountCode'] = [
self::SerializerType_Public => DomainAuthorizedSummitRegistrationDiscountCodeSerializer::class,
self::SerializerType_CSV => DomainAuthorizedSummitRegistrationDiscountCodeSerializer::class,
self::SerializerType_PreValidation => SummitRegistrationPromoCodePreValidationSerializer::class,
];

$this->registry['DomainAuthorizedSummitRegistrationPromoCode'] = [
self::SerializerType_Public => DomainAuthorizedSummitRegistrationPromoCodeSerializer::class,
self::SerializerType_CSV => DomainAuthorizedSummitRegistrationPromoCodeSerializer::class,
self::SerializerType_PreValidation => SummitRegistrationPromoCodePreValidationSerializer::class,
];

$this->registry['PresentationSpeakerSummitAssistanceConfirmationRequest'] = PresentationSpeakerSummitAssistanceConfirmationRequestSerializer::class;
$this->registry['SummitRegistrationDiscountCodeTicketTypeRule'] = SummitRegistrationDiscountCodeTicketTypeRuleSerializer::class;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php namespace ModelSerializers;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use models\summit\DomainAuthorizedSummitRegistrationDiscountCode;

/**
* Class DomainAuthorizedSummitRegistrationDiscountCodeSerializer
* @package ModelSerializers
*/
class DomainAuthorizedSummitRegistrationDiscountCodeSerializer
extends SummitRegistrationDiscountCodeSerializer
{
protected static $array_mappings = [
'AllowedEmailDomains' => 'allowed_email_domains:json_string_array',
'QuantityPerAccount' => 'quantity_per_account:json_int',
'AutoApply' => 'auto_apply:json_boolean',
];

protected static $allowed_relations = [
'allowed_ticket_types',
];

/**
* @param null $expand
* @param array $fields
* @param array $relations
* @param array $params
* @return array
*/
public function serialize($expand = null, array $fields = [], array $relations = [], array $params = [])
{
$code = $this->object;
if (!$code instanceof DomainAuthorizedSummitRegistrationDiscountCode) return [];
$values = parent::serialize($expand, $fields, $relations, $params);

// RE-ADD allowed_ticket_types (parent discount serializer unsets it).
// Check both relations (default serialization) and expand (explicit ?expand= request).
$needs_allowed_ticket_types = in_array('allowed_ticket_types', $relations)
|| (!empty($expand) && str_contains($expand, 'allowed_ticket_types'));
if ($needs_allowed_ticket_types && !isset($values['allowed_ticket_types'])) {
$ticket_types = [];
foreach ($code->getAllowedTicketTypes() as $ticket_type) {
$ticket_types[] = $ticket_type->getId();
}
$values['allowed_ticket_types'] = $ticket_types;
}

// Transient remaining_quantity_per_account (set by service layer)
$values['remaining_quantity_per_account'] = $code->getRemainingQuantityPerAccount();

return $values;
}

protected static $expand_mappings = [
'allowed_ticket_types' => [
'type' => \Libs\ModelSerializers\Many2OneExpandSerializer::class,
'getter' => 'getAllowedTicketTypes',
],
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php namespace ModelSerializers;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use models\summit\DomainAuthorizedSummitRegistrationPromoCode;

/**
* Class DomainAuthorizedSummitRegistrationPromoCodeSerializer
* @package ModelSerializers
*/
class DomainAuthorizedSummitRegistrationPromoCodeSerializer
extends SummitRegistrationPromoCodeSerializer
{
protected static $array_mappings = [
'AllowedEmailDomains' => 'allowed_email_domains:json_string_array',
'QuantityPerAccount' => 'quantity_per_account:json_int',
'AutoApply' => 'auto_apply:json_boolean',
];

/**
* @param null $expand
* @param array $fields
* @param array $relations
* @param array $params
* @return array
*/
public function serialize($expand = null, array $fields = [], array $relations = [], array $params = [])
{
$code = $this->object;
if (!$code instanceof DomainAuthorizedSummitRegistrationPromoCode) return [];
$values = parent::serialize($expand, $fields, $relations, $params);

// Transient remaining_quantity_per_account (set by service layer)
$values['remaining_quantity_per_account'] = $code->getRemainingQuantityPerAccount();

return $values;
}
}
Loading
Loading