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 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_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.',
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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@mulldug please cast $summit_id to int

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",
schema: new OA\Schema(type: "integer")
),
new OA\Parameter(
name: "filter",
in: "query",
required: false,
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.",
schema: new OA\Schema(type: "string", example: "has_accepted_presentations==true")
),
],
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));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@mulldug The getSpeakersActivitiesCount validation rules for has_accepted_presentations, has_alternate_presentations, and has_rejected_presentations use sometimes|required|string|in:true,false,
while the sibling getSubmittersActivitiesCount uses sometimes|string|in:true,false (no required). Since both endpoints accept the same filter semantics, the validation should be consistent
— pick one form and apply it to both.

'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]);
});
}
}
3 changes: 1 addition & 2 deletions app/ModelSerializers/Summit/SummitSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,13 @@ 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'));

}

if (!$has_bookable_rooms_profile &&
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;
}
44 changes: 43 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,48 @@ function ($query) {
});
}

/**
* @param Summit $summit
* @param Filter|null $filter
* @return int
* @throws \Doctrine\DBAL\Exception
*/
public function getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int
{
// 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
WHERE __p.created_by = e AND __p.summit = :summit
)")
->setParameter("summit", $summit);

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

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

// Count distinct presentations using the member query as a subquery — no PHP ID materialization.
$countQb = $this->getEntityManager()->createQueryBuilder()
->select("COUNT(DISTINCT p.id)")
->from('models\summit\Presentation', 'p')
->where('p.summit = :summit_outer')
->andWhere("p.created_by IN ({$qb->getDQL()})");
Copy link
Copy Markdown
Collaborator

@smarcet smarcet Jun 4, 2026

Choose a reason for hiding this comment

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

@mulldug
Embedding $qb->getDQL() via string interpolation is fragile in two ways.

First, the filter mappings for this repository contain hardcoded :summit parameter references inside their EXISTS subqueries (presentations_track_id, has_accepted_presentations, presentations_title, etc.). Those parameters are propagated by the copy loop below, but if any future filter mapping introduces a parameter named :summit_outer it will silently overwrite the outer query's summit binding and return incorrect counts.

Second, getDQL() is an internal Doctrine QueryBuilder API with no stability guarantee on its output format.

Consider using Expr\In instead of string interpolation ($countQb->expr()->in('p.created_by', $qb->getDQL())) and renaming the inner query's :summit parameter to something unambiguous (e.g. :submitter_summit) to make the parameter boundary explicit.


$countQb->setParameter('summit_outer', $summit);
foreach ($qb->getParameters() as $param) {
$countQb->setParameter($param->getName(), $param->getValue(), $param->getType());
}

return intval($countQb->getQuery()->getSingleScalarResult());
}

/**
* @param PagingInfo $paging_info
* @param Filter|null $filter
Expand Down
33 changes: 33 additions & 0 deletions app/Repositories/Summit/DoctrineSpeakerRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,39 @@ function ($query) {
}


/**
* @param Summit $summit
* @param Filter|null $filter
* @return int
* @throws \Doctrine\DBAL\Exception
*/
public function getUniqueActivitiesCountBySummit(Summit $summit, Filter $filter = null): int
{
// Single query: cross-join Presentation × PresentationSpeaker, then filter to
// rows where the speaker is either an assigned speaker OR the moderator.
// COUNT(DISTINCT p.id) deduplicates in SQL — no PHP-side array_unique needed.
// All aliases (e, m, rr) are top-level, so getFilterMappings() applies unchanged.
$countQb = $this->getEntityManager()->createQueryBuilder()
->select('COUNT(DISTINCT p.id)')
->from('models\summit\Presentation', 'p')
->from('models\summit\PresentationSpeaker', 'e')
Copy link
Copy Markdown
Collaborator

@smarcet smarcet Jun 4, 2026

Choose a reason for hiding this comment

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

@mulldug
This FROM clause creates an unbounded cross-join: PresentationSpeaker is not scoped to the summit, so the query starts from a product of (presentations in summit) × (every speaker in the entire database) before the EXISTS/moderator clause narrows it down. On a large deployment — say 1K summit presentations and 10K total speakers — the engine evaluates 10M intermediate rows per call.

The member repository avoids this with a two-stage subquery: first collect matching member IDs, then count presentations for those IDs. The same pattern works here — collect matching speaker IDs in a subquery scoped to this summit's assignments, then feed that as an IN subquery into the count.

->leftJoin('e.registration_request', 'rr')
->leftJoin('e.member', 'm')
->where('p.summit = :summit')
->andWhere(
'EXISTS (SELECT 1 FROM App\Models\Foundation\Summit\Speakers\PresentationSpeakerAssignment __cnt'
. ' WHERE __cnt.presentation = p AND __cnt.speaker = e)'
. ' OR p.moderator = e'
)
->setParameter('summit', $summit);

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

return intval($countQb->getQuery()->getSingleScalarResult());
}

/**
* @param Summit $summit
* @param PagingInfo $paging_info
Expand Down
Loading
Loading