Skip to content

Commit b517ad2

Browse files
authored
Merge pull request #508 from HiEventsDev/develop
Fix some attendees not being found when using QR checkin (#507)
2 parents 7f197b1 + 43a416f commit b517ad2

File tree

10 files changed

+268
-10
lines changed

10 files changed

+268
-10
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace HiEvents\Http\Actions\CheckInLists\Public;
4+
5+
use HiEvents\Exceptions\CannotCheckInException;
6+
use HiEvents\Http\Actions\BaseAction;
7+
use HiEvents\Resources\Attendee\AttendeeWithCheckInPublicResource;
8+
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeePublicHandler;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\Request;
11+
use Symfony\Component\HttpFoundation\Response;
12+
13+
class GetCheckInListAttendeePublicAction extends BaseAction
14+
{
15+
public function __construct(
16+
private readonly GetCheckInListAttendeePublicHandler $getCheckInListAttendeePublicHandler,
17+
)
18+
{
19+
}
20+
21+
public function __invoke(string $shortId, string $attendeePublicId, Request $request): JsonResponse
22+
{
23+
try {
24+
$attendee = $this->getCheckInListAttendeePublicHandler->handle(
25+
shortId: $shortId,
26+
attendeePublicId: $attendeePublicId,
27+
);
28+
} catch (CannotCheckInException $e) {
29+
return $this->errorResponse(
30+
message: $e->getMessage(),
31+
statusCode: Response::HTTP_FORBIDDEN,
32+
);
33+
}
34+
35+
return $this->resourceResponse(
36+
resource: AttendeeWithCheckInPublicResource::class,
37+
data: $attendee,
38+
);
39+
}
40+
}

backend/app/Repository/Eloquent/AttendeeRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa
118118

119119
return $this->simplePaginateWhere(
120120
where: $where,
121-
limit: 100,
121+
limit: min($params->per_page, 250),
122122
);
123123
}
124124
}

backend/app/Repository/Eloquent/BaseRepository.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function paginateWhere(
9999

100100
public function simplePaginateWhere(
101101
array $where,
102-
?int $limit = null,
102+
?int $limit = null,
103103
array $columns = self::DEFAULT_COLUMNS,
104104
): Paginator
105105
{
@@ -131,9 +131,9 @@ public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): Domai
131131
}
132132

133133
public function findFirstByField(
134-
string $field,
134+
string $field,
135135
?string $value = null,
136-
array $columns = ['*']
136+
array $columns = ['*']
137137
): ?DomainObjectInterface
138138
{
139139
$model = $this->model->where($field, '=', $value)->first($columns);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace HiEvents\Services\Application\Handlers\CheckInList\Public;
4+
5+
use HiEvents\DomainObjects\AttendeeDomainObject;
6+
use HiEvents\DomainObjects\CheckInListDomainObject;
7+
use HiEvents\DomainObjects\EventDomainObject;
8+
use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract;
9+
use HiEvents\DomainObjects\ProductDomainObject;
10+
use HiEvents\Exceptions\CannotCheckInException;
11+
use HiEvents\Helper\DateHelper;
12+
use HiEvents\Repository\Eloquent\Value\Relationship;
13+
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
14+
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
15+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
16+
17+
class GetCheckInListAttendeePublicHandler
18+
{
19+
public function __construct(
20+
private readonly AttendeeRepositoryInterface $attendeeRepository,
21+
private readonly CheckInListRepositoryInterface $checkInListRepository,
22+
)
23+
{
24+
}
25+
26+
/**
27+
* @throws CannotCheckInException
28+
*/
29+
public function handle(string $shortId, string $attendeePublicId): AttendeeDomainObject
30+
{
31+
$checkInList = $this->checkInListRepository
32+
->loadRelation(ProductDomainObject::class)
33+
->loadRelation(new Relationship(EventDomainObject::class, name: 'event'))
34+
->findFirstWhere([
35+
CheckInListDomainObjectAbstract::SHORT_ID => $shortId,
36+
]);
37+
38+
if (!$checkInList) {
39+
throw new ResourceNotFoundException(__('Check-in list not found'));
40+
}
41+
42+
$this->validateCheckInListIsActive($checkInList);
43+
44+
return $this->attendeeRepository->findFirstWhere([
45+
'public_id' => $attendeePublicId,
46+
'event_id' => $checkInList->getEventId(),
47+
]);
48+
}
49+
50+
/**
51+
* @todo - Move this to its own service. It's used 3 times
52+
* @throws CannotCheckInException
53+
*/
54+
private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void
55+
{
56+
if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) {
57+
throw new CannotCheckInException(__('Check-in list has expired'));
58+
}
59+
60+
if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) {
61+
throw new CannotCheckInException(__('Check-in list is not active yet'));
62+
}
63+
}
64+
}

backend/routes/api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use HiEvents\Http\Actions\CheckInLists\GetCheckInListsAction;
3333
use HiEvents\Http\Actions\CheckInLists\Public\CreateAttendeeCheckInPublicAction;
3434
use HiEvents\Http\Actions\CheckInLists\Public\DeleteAttendeeCheckInPublicAction;
35+
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeePublicAction;
3536
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeesPublicAction;
3637
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction;
3738
use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction;
@@ -340,6 +341,7 @@ function (Router $router): void {
340341
// Check-In
341342
$router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class);
342343
$router->get('/check-in-lists/{check_in_list_short_id}/attendees', GetCheckInListAttendeesPublicAction::class);
344+
$router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}', GetCheckInListAttendeePublicAction::class);
343345
$router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class);
344346
$router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class);
345347
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
namespace Tests\Unit\Services\Application\Handlers\CheckInList\Public;
4+
5+
use HiEvents\DomainObjects\AttendeeDomainObject;
6+
use HiEvents\DomainObjects\CheckInListDomainObject;
7+
use HiEvents\Exceptions\CannotCheckInException;
8+
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
9+
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
10+
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeePublicHandler;
11+
use Mockery as m;
12+
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
13+
use Tests\TestCase;
14+
15+
class GetCheckInListAttendeePublicHandlerTest extends TestCase
16+
{
17+
private CheckInListRepositoryInterface $checkInListRepository;
18+
private AttendeeRepositoryInterface $attendeeRepository;
19+
private GetCheckInListAttendeePublicHandler $handler;
20+
21+
protected function setUp(): void
22+
{
23+
parent::setUp();
24+
25+
$this->checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
26+
$this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
27+
28+
$this->handler = new GetCheckInListAttendeePublicHandler(
29+
$this->attendeeRepository,
30+
$this->checkInListRepository
31+
);
32+
}
33+
34+
public function testHandleThrowsNotFoundIfCheckInListMissing(): void
35+
{
36+
$this->checkInListRepository
37+
->shouldReceive('loadRelation')
38+
->andReturnSelf()
39+
->times(2);
40+
41+
$this->checkInListRepository
42+
->shouldReceive('findFirstWhere')
43+
->once()
44+
->andReturnNull();
45+
46+
$this->expectException(ResourceNotFoundException::class);
47+
48+
$this->handler->handle('short-id', 'attendee-public-id');
49+
}
50+
51+
public function testHandleThrowsCannotCheckInIfListExpired(): void
52+
{
53+
$checkInList = m::mock(CheckInListDomainObject::class);
54+
$checkInList->shouldReceive('getExpiresAt')->twice()->andReturn(now()->subMinute());
55+
56+
$this->checkInListRepository
57+
->shouldReceive('loadRelation')
58+
->andReturnSelf()
59+
->times(2);
60+
61+
$this->checkInListRepository
62+
->shouldReceive('findFirstWhere')
63+
->once()
64+
->andReturn($checkInList);
65+
66+
$this->expectException(CannotCheckInException::class);
67+
68+
$this->handler->handle('short-id', 'attendee-public-id');
69+
}
70+
71+
public function testHandleThrowsCannotCheckInIfListNotActiveYet(): void
72+
{
73+
$checkInList = m::mock(CheckInListDomainObject::class);
74+
$checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null);
75+
$checkInList->shouldReceive('getActivatesAt')->twice()->andReturn(now()->addMinute());
76+
77+
$this->checkInListRepository
78+
->shouldReceive('loadRelation')
79+
->andReturnSelf()
80+
->times(2);
81+
82+
$this->checkInListRepository
83+
->shouldReceive('findFirstWhere')
84+
->once()
85+
->andReturn($checkInList);
86+
87+
$this->expectException(CannotCheckInException::class);
88+
89+
$this->handler->handle('short-id', 'attendee-public-id');
90+
}
91+
92+
public function testHandleReturnsAttendeeSuccessfully(): void
93+
{
94+
$checkInList = m::mock(CheckInListDomainObject::class);
95+
$checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null);
96+
$checkInList->shouldReceive('getActivatesAt')->once()->andReturn(null);
97+
$checkInList->shouldReceive('getEventId')->once()->andReturn(123);
98+
99+
$attendee = m::mock(AttendeeDomainObject::class);
100+
101+
$this->checkInListRepository
102+
->shouldReceive('loadRelation')
103+
->andReturnSelf()
104+
->times(2);
105+
106+
$this->checkInListRepository
107+
->shouldReceive('findFirstWhere')
108+
->once()
109+
->andReturn($checkInList);
110+
111+
$this->attendeeRepository
112+
->shouldReceive('findFirstWhere')
113+
->once()
114+
->with([
115+
'public_id' => 'attendee-public-id',
116+
'event_id' => 123,
117+
])
118+
->andReturn($attendee);
119+
120+
$result = $this->handler->handle('short-id', 'attendee-public-id');
121+
122+
$this->assertSame($attendee, $result);
123+
}
124+
}

frontend/src/api/check-in.client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export const publicCheckInClient = {
1818
const response = await publicApi.get<GenericPaginatedResponse<Attendee>>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination));
1919
return response.data;
2020
},
21+
getCheckInListAttendee: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => {
22+
const response = await publicApi.get<GenericDataResponse<Attendee>>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}`);
23+
return response.data;
24+
},
2125
createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam, action: 'check-in' | 'check-in-and-mark-order-as-paid') => {
2226
const response = await publicApi.post<GenericDataResponse<PublicCheckIn[]>>(`/check-in-lists/${checkInListShortId}/check-ins`, {
2327
"attendees": [

frontend/src/components/layouts/CheckIn/index.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {NoResultsSplash} from "../../common/NoResultsSplash";
2525
import {Countdown} from "../../common/Countdown";
2626
import Truncate from "../../common/Truncate";
2727
import {Header} from "../../common/Header";
28+
import {publicCheckInClient} from "../../../api/check-in.client.ts";
2829

2930
const CheckIn = () => {
3031
const networkStatus = useNetwork();
@@ -49,7 +50,7 @@ const CheckIn = () => {
4950
const queryFilters: QueryFilters = {
5051
pageNumber: 1,
5152
query: searchQueryDebounced,
52-
perPage: 100,
53+
perPage: 150,
5354
filterFields: {
5455
status: {operator: 'eq', value: 'ACTIVE'},
5556
},
@@ -134,11 +135,21 @@ const CheckIn = () => {
134135

135136
const handleQrCheckIn = async (attendeePublicId: string) => {
136137
// Find the attendee in the current list or fetch them
137-
const attendee = attendees?.find(a => a.public_id === attendeePublicId);
138+
let attendee = attendees?.find(a => a.public_id === attendeePublicId);
138139

139140
if (!attendee) {
140-
showError(t`Attendee not found`);
141-
return;
141+
try {
142+
const {data} = await publicCheckInClient.getCheckInListAttendee(checkInListShortId, attendeePublicId);
143+
attendee = data;
144+
} catch (error) {
145+
showError(t`Unable to fetch attendee`);
146+
return;
147+
}
148+
149+
if (!attendee) {
150+
showError(t`Attendee not found`);
151+
return;
152+
}
142153
}
143154

144155
const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {useQuery} from '@tanstack/react-query';
2+
import {Attendee, GenericPaginatedResponse, IdParam} from '../types';
3+
import {publicCheckInClient} from "../api/check-in.client";
4+
5+
export const GET_CHECK_IN_LIST_ATTENDEE_PUBLIC_QUERY_KEY = 'getCheckInListAttendee';
6+
7+
export const useGetCheckInListAttendee = (checkInListShortId: IdParam, attendeePublicId: IdParam) => {
8+
return useQuery<GenericPaginatedResponse<Attendee>>({
9+
queryKey: [GET_CHECK_IN_LIST_ATTENDEE_PUBLIC_QUERY_KEY, checkInListShortId, attendeePublicId],
10+
queryFn: async () => {
11+
return await publicCheckInClient.getCheckInListAttendee(checkInListShortId, attendeePublicId);
12+
},
13+
});
14+
};

frontend/src/queries/useGetCheckInListAttendeesPublic.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ export const useGetCheckInListAttendees = (checkInListShortId: IdParam, paginati
88
return useQuery<GenericPaginatedResponse<Attendee>>({
99
queryKey: [GET_CHECK_IN_LIST_ATTENDEES_PUBLIC_QUERY_KEY, checkInListShortId, pagination],
1010
queryFn: async () => {
11-
const data = await publicCheckInClient.getCheckInListAttendees(checkInListShortId, pagination);
12-
return data;
11+
return await publicCheckInClient.getCheckInListAttendees(checkInListShortId, pagination);
1312
},
1413
enabled: enabled,
1514
});

0 commit comments

Comments
 (0)