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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ QUEUE_DATABASE=
MAIL_DRIVER=sendgrid
SENDGRID_API_KEY='YOUR_SENDGRID_API_KEY'

CORS_ALLOWED_HEADERS=origin, content-type, accept, authorization, x-requested-with
CORS_ALLOWED_METHODS=GET, POST, OPTIONS, PUT, DELETE
CORS_ALLOWED_HEADERS="origin, content-type, accept, authorization, x-requested-with"
CORS_ALLOWED_METHODS="GET, POST, OPTIONS, PUT, DELETE"
CORS_USE_PRE_FLIGHT_CACHING=true
CORS_MAX_AGE=3200
CORS_EXPOSED_HEADERS=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ public/apc.php
.nvmrc
.codegraph
docs/
docker-compose.override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,110 @@ function ($page, $per_page, $filter, $order, $applyExtraFilters) use ($summit) {
);
}

#[OA\Get(
path: '/api/v1/summits/{id}/speakers/all/events/count',
operationId: 'getSpeakersActivitiesCount',
description: 'Get the count of unique activities associated with speakers matching the filter criteria',
tags: ['Summit Speakers'],
security: [['summit_speakers_oauth2' => [
SummitScopes::ReadSummitData,
SummitScopes::ReadAllSummitData
]]],
parameters: [
new OA\Parameter(
name: 'id',
description: 'Summit ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer')
),
new OA\Parameter(
name: 'filter',
description: 'Filter by id, first_name, last_name, email, full_name, member_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, has_media_upload_with_type, has_not_media_upload_with_type, etc.',
in: 'query',
required: false,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: 'Unique activities count',
content: new OA\JsonContent(
properties: [new OA\Property(property: 'count', type: 'integer')]
)
),
new OA\Response(response: Response::HTTP_NOT_FOUND, description: 'Not Found'),
new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: 'Server Error'),
]
)]
public function getSpeakersActivitiesCount($summit_id)
{
return $this->processRequest(function () use ($summit_id) {

$summit = SummitFinderStrategyFactory::build($this->getRepository(), $this->getResourceServerContext())->find($summit_id);
if (is_null($summit)) return $this->error404();

$filter = null;

if (Request::has('filter')) {
$filter = FilterParser::parse(Request::input('filter'), [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '@@', '=='],
'last_name' => ['=@', '@@', '=='],
'email' => ['=@', '@@', '=='],
'full_name' => ['=@', '@@', '=='],
'member_id' => ['=='],
'member_user_external_id' => ['=='],
'has_accepted_presentations' => ['=='],
'has_alternate_presentations' => ['=='],
'has_rejected_presentations' => ['=='],
'presentations_track_id' => ['=='],
'presentations_track_group_id' => ['=='],
'presentations_selection_plan_id' => ['=='],
'presentations_type_id' => ['=='],
'presentations_title' => ['=@', '@@', '=='],
'presentations_abstract' => ['=@', '@@', '=='],
'presentations_submitter_full_name' => ['=@', '@@', '=='],
'presentations_submitter_email' => ['=@', '@@', '=='],
'has_media_upload_with_type' => ['=='],
'has_not_media_upload_with_type' => ['=='],
]);
}

if (!is_null($filter)) {
$filter->validate([
'id' => 'sometimes|integer',
'not_id' => 'sometimes|integer',
'first_name' => 'sometimes|string',
'last_name' => 'sometimes|string',
'email' => 'sometimes|string',
'full_name' => 'sometimes|string',
'member_id' => 'sometimes|integer',
'member_user_external_id' => 'sometimes|integer',
'has_accepted_presentations' => 'sometimes|required|string|in:true,false',
'has_alternate_presentations' => 'sometimes|required|string|in:true,false',
'has_rejected_presentations' => 'sometimes|required|string|in:true,false',
'presentations_track_id' => 'sometimes|integer',
'presentations_track_group_id' => 'sometimes|integer',
'presentations_selection_plan_id' => 'sometimes|integer',
'presentations_type_id' => 'sometimes|integer',
'presentations_title' => 'sometimes|string',
'presentations_abstract' => 'sometimes|string',
'presentations_submitter_full_name' => 'sometimes|string',
'presentations_submitter_email' => 'sometimes|string',
'has_media_upload_with_type' => 'sometimes|integer',
'has_not_media_upload_with_type' => 'sometimes|integer',
]);
}

$count = $this->speaker_repository->getUniqueActivitiesCountBySummit($summit, $filter);

return $this->ok(['count' => $count]);
});
}

/**
* @param $summit_id
* @return mixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -495,4 +495,111 @@ public function send($summit_id)
return $this->ok();
});
}

#[OA\Get(
path: "/api/v1/summits/{id}/submitters/all/events/count",
summary: "Get unique activities count for submitters",
operationId: "getSubmittersActivitiesCount",
tags: ["Summit Submitters"],
security: [['summit_submitters_oauth2' => [
SummitScopes::ReadSummitData,
SummitScopes::ReadAllSummitData,
]]],
parameters: [
new OA\Parameter(
name: "id",
in: "path",
required: true,
description: "Summit ID or slug",
schema: new OA\Schema(type: "string")
),
new OA\Parameter(
name: "filter",
in: "query",
required: false,
description: "Filter query (supports multiple operators). Filterable fields: id, first_name, last_name, email, full_name, member_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_selection_plan_id, presentations_type_id, is_speaker, has_media_upload_with_type, has_not_media_upload_with_type.",
schema: new OA\Schema(type: "string", example: "has_accepted_presentations==true")
Comment on lines +520 to +521
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

OpenAPI filter description lists fewer filterable fields than the implementation accepts

The description string on line 520 explicitly names the accepted filter fields but omits six fields that are in fact parsed by FilterParser::parse: not_id, member_user_external_id, presentations_title, presentations_abstract, presentations_submitter_full_name, and presentations_submitter_email. Clients reading the spec will believe those fields are unsupported and won't use them, and any contract-test tooling that enforces the documented field list will reject valid requests.

Either update the description to list all accepted fields, or intentionally remove the undocumented fields from the parse map (if they truly shouldn't be filterable on this endpoint).

📝 Proposed fix: align description with implementation
-                description: "Filter query (supports multiple operators). Filterable fields: id, first_name, last_name, email, full_name, member_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_selection_plan_id, presentations_type_id, is_speaker, has_media_upload_with_type, has_not_media_upload_with_type.",
+                description: "Filter query (supports multiple operators). Filterable fields: id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, is_speaker, has_media_upload_with_type, has_not_media_upload_with_type.",

Also applies to: 549-571

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php`
around lines 520 - 521, The OpenAPI description string in
OAuth2SummitSubmittersApiController that documents filterable fields is out of
sync with the actual parser used (FilterParser::parse) — update the description
string to include the missing filter fields: not_id, member_user_external_id,
presentations_title, presentations_abstract, presentations_submitter_full_name,
and presentations_submitter_email so the spec matches the implementation (or
alternatively remove those keys from the parse map if they should not be
supported); locate the description value near the filter schema definition in
the controller and make the textual list consistent with FilterParser::parse.

),
],
responses: [
new OA\Response(
response: Response::HTTP_OK,
description: "Unique activities count",
content: new OA\JsonContent(
properties: [new OA\Property(property: "count", type: "integer")]
)
),
new OA\Response(response: Response::HTTP_BAD_REQUEST, description: "Bad Request"),
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"),
new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"),
]
)]
public function getSubmittersActivitiesCount($summit_id)
{
return $this->processRequest(function () use ($summit_id) {

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

$filter = null;

if (Request::has('filter')) {
$filter = FilterParser::parse(Request::input('filter'), [
'id' => ['=='],
'not_id' => ['=='],
'first_name' => ['=@', '@@', '=='],
'last_name' => ['=@', '@@', '=='],
'email' => ['=@', '@@', '=='],
'full_name' => ['=@', '@@', '=='],
'member_id' => ['=='],
'member_user_external_id' => ['=='],
'has_accepted_presentations' => ['=='],
'has_alternate_presentations' => ['=='],
'has_rejected_presentations' => ['=='],
'presentations_track_id' => ['=='],
'presentations_selection_plan_id' => ['=='],
'presentations_type_id' => ['=='],
'presentations_title' => ['=@', '@@', '=='],
'presentations_abstract' => ['=@', '@@', '=='],
'presentations_submitter_full_name' => ['=@', '@@', '=='],
'presentations_submitter_email' => ['=@', '@@', '=='],
'is_speaker' => ['=='],
'has_media_upload_with_type' => ['=='],
'has_not_media_upload_with_type' => ['=='],
]);
}

if (!is_null($filter)) {
$filter->validate([
'id' => 'sometimes|integer',
'not_id' => 'sometimes|integer',
'first_name' => 'sometimes|string',
'last_name' => 'sometimes|string',
'email' => 'sometimes|string',
'full_name' => 'sometimes|string',
'member_id' => 'sometimes|integer',
'member_user_external_id' => 'sometimes|integer',
'has_accepted_presentations' => 'sometimes|string|in:true,false',
'has_alternate_presentations' => 'sometimes|string|in:true,false',
'has_rejected_presentations' => 'sometimes|string|in:true,false',
'presentations_track_id' => 'sometimes|integer',
'presentations_selection_plan_id' => 'sometimes|integer',
'presentations_type_id' => 'sometimes|integer',
'presentations_title' => 'sometimes|string',
'presentations_abstract' => 'sometimes|string',
'presentations_submitter_full_name' => 'sometimes|string',
'presentations_submitter_email' => 'sometimes|string',
'is_speaker' => 'sometimes|string|in:true,false',
'has_media_upload_with_type' => 'sometimes|integer',
'has_not_media_upload_with_type' => 'sometimes|integer',
]);
}

$count = $this->repository->getUniqueActivitiesCountBySummit($summit, $filter);

return $this->ok(['count' => $count]);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public function handle($request, Closure $next)
}
if (
$token_info->getApplicationType() === 'JS_CLIENT'
&& (is_null($origin) || empty($origin)|| str_contains($token_info->getAllowedOrigins(), $origin) === false )
&& (is_null($origin) || empty($origin)|| str_contains($token_info->getAllowedOrigins(), rtrim($origin, '/')) === false )
) {
//check origins
throw new OAuth2ResourceServerException(
Expand Down
30 changes: 16 additions & 14 deletions app/ModelSerializers/Summit/SummitSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,24 +320,26 @@ public function serialize($expand = null, array $fields = [], array $relations =
if (!$has_registration_profile &&
!is_null($build_default_payment_gateway_profile_strategy)
) {

$values['payment_profiles'][] =
SerializerRegistry::getInstance()->getSerializer
(
$build_default_payment_gateway_profile_strategy->build(IPaymentConstants::ApplicationTypeRegistration),
$this->getSerializerType()
)->serialize(AbstractSerializer::filterExpandByPrefix($expand, 'payment_profiles'));

try {
$profile = $build_default_payment_gateway_profile_strategy->build(IPaymentConstants::ApplicationTypeRegistration);
$serializer = SerializerRegistry::getInstance()->getSerializer($profile, $this->getSerializerType());
if (!is_null($serializer))
$values['payment_profiles'][] = $serializer->serialize(AbstractSerializer::filterExpandByPrefix($expand, 'payment_profiles'));
} catch (\Exception $ex) {
Log::warning($ex->getMessage());
}
}

if (!$has_bookable_rooms_profile &&
!is_null($build_default_payment_gateway_profile_strategy)) {
$values['payment_profiles'][] =
SerializerRegistry::getInstance()->getSerializer
(
$build_default_payment_gateway_profile_strategy->build(IPaymentConstants::ApplicationTypeBookableRooms),
$this->getSerializerType()
)->serialize(AbstractSerializer::filterExpandByPrefix($expand, 'payment_profiles'));
try {
$profile = $build_default_payment_gateway_profile_strategy->build(IPaymentConstants::ApplicationTypeBookableRooms);
$serializer = SerializerRegistry::getInstance()->getSerializer($profile, $this->getSerializerType());
if (!is_null($serializer))
$values['payment_profiles'][] = $serializer->serialize(AbstractSerializer::filterExpandByPrefix($expand, 'payment_profiles'));
} catch (\Exception $ex) {
Log::warning($ex->getMessage());
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions app/Models/Foundation/Main/Repositories/IMemberRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,12 @@ public function getSubmittersBySummit(Summit $summit, PagingInfo $paging_info, F
* @throws \Doctrine\DBAL\Exception
*/
public function getSubmittersIdsBySummit(Summit $summit, PagingInfo $paging_info, Filter $filter = null, Order $order = null);

/**
* @param Summit $summit
* @param Filter|null $filter
* @return int
* @throws \Doctrine\DBAL\Exception
*/
public function getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int;
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,12 @@ public function getSpeakersIdsBySummit(Summit $summit, PagingInfo $paging_info,
* @return PagingResponse
*/
public function getAllCompaniesByPage(PagingInfo $paging_info, Filter $filter = null, Order $order = null);

/**
* @param Summit $summit
* @param Filter|null $filter
* @return int
* @throws \Doctrine\DBAL\Exception
*/
public function getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int;
}
52 changes: 51 additions & 1 deletion app/Repositories/Summit/DoctrineMemberRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ protected function getBaseEntity()
*/
protected function applyExtraJoins(QueryBuilder $query, ?Filter $filter = null, ?Order $order = null): QueryBuilder
{
if($filter->hasFilter("summit_id") || $filter->hasFilter("schedule_event_id")){
if(!is_null($filter) && ($filter->hasFilter("summit_id") || $filter->hasFilter("schedule_event_id"))){
$query
->leftJoin("e.schedule","sch")
->leftJoin("sch.event", "evt")
Expand Down Expand Up @@ -638,6 +638,56 @@ function ($query) {
});
}

/**
* @param Summit $summit
* @param Filter|null $filter
* @return int
* @throws \Doctrine\DBAL\Exception
*/
public function getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int
{
// Step 1: collect distinct member IDs matching the summit + filter using the
// same base query / filter mappings as getSubmittersBySummit.
$qb = $this->getEntityManager()->createQueryBuilder()
->distinct(true)
->select("e.id")
->from($this->getBaseEntity(), "e")
->where("
EXISTS (
SELECT __p.id FROM models\summit\Presentation __p
JOIN __p.created_by __cb93 WITH __cb93 = e.id
WHERE __p.summit = :summit
)")
->setParameter("summit", $summit);

$qb = $this->applyExtraJoins($qb, $filter);

if (!is_null($filter)) {
$filter->apply2Query($qb, $this->getFilterMappings($filter));
}

$memberIds = array_column($qb->getQuery()->getArrayResult(), 'id');

if (empty($memberIds)) return 0;

// Step 2: count distinct presentations submitted by those members.
$sql = <<<SQL
SELECT COUNT(DISTINCT p.ID) AS cnt
FROM Presentation p
INNER JOIN SummitEvent se ON se.ID = p.ID
WHERE se.SummitID = :summit_id
AND p.CreatedByID IN (:member_ids)
SQL;

$stm = $this->getEntityManager()->getConnection()->executeQuery(
$sql,
['summit_id' => $summit->getId(), 'member_ids' => $memberIds],
['summit_id' => \Doctrine\DBAL\ParameterType::INTEGER, 'member_ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
);

return intval($stm->fetchOne());
}
Comment on lines +647 to +689
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Loading all member IDs into PHP for the IN clause is a scalability hazard.

Step 1 fetches every distinct matching member ID in one unbounded query (getArrayResult() with no LIMIT). For a large summit with thousands of submitters, this loads a potentially very large array into PHP memory and then constructs a correspondingly long raw-SQL IN (...) list. MySQL (and most RDBMS) experience query-plan degradation at high IN-list cardinalities, and the intermediate PHP array also grows proportionally.

The analogous getSubmittersIdsBySummit and getSubmittersCountBySummit-style methods in this repo are paginated; this method has no safety valve. The same concern applies to the DoctrineSpeakerRepository::getUniqueActivitiesCountBySummit implementation that is not in this review.

For typical summit sizes (hundreds to a few thousand submitters) this is acceptable, but it should be acknowledged as a scale limit. A correlated-subquery approach that stays within the database would eliminate the round-trip:

SELECT COUNT(DISTINCT p.ID) AS cnt
FROM Presentation p
INNER JOIN SummitEvent se ON se.ID = p.ID
WHERE se.SummitID = :summit_id
  AND p.CreatedByID IN (
      SELECT m.ID
      FROM Member m
      WHERE EXISTS (
          SELECT 1
          FROM Presentation p2
          WHERE p2.CreatedByID = m.ID
            AND p2.SummitID    = :summit_id
          -- filter conditions translated here
      )
  )

This is high-effort to implement given the complex DQL filter system, but worth tracking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Repositories/Summit/DoctrineMemberRepository.php` around lines 647 - 689,
The current getUniqueActivitiesCountBySummit method loads all matching member
IDs into PHP and uses them in an IN(...) clause which is a scalability hazard;
replace the two-step approach with a single DB-side query (either DQL or raw
SQL) that counts DISTINCT Presentation IDs using a correlated subquery or EXISTS
to restrict p.CreatedByID to members who match the summit+filter, so no IDs are
materialized in PHP. Locate getUniqueActivitiesCountBySummit and remove the
array_column/getArrayResult logic; build a single query that keeps :summit_id as
a parameter and expresses the member filter via an EXISTS (or a subselect from
Member) so you can still reuse the filter mapping logic (applyExtraJoins /
getFilterMappings / Filter::apply2Query) by applying it into the subquery/DQL
where appropriate; preserve parameter typing for summit_id and avoid passing
large arrays to the DB driver.


/**
* @param PagingInfo $paging_info
* @param Filter|null $filter
Expand Down
Loading
Loading