Skip to content

Commit 31b61e7

Browse files
committed
feat(badge-scan): support attendee email lookup with source audit trail
1 parent 4879a4a commit 31b61e7

7 files changed

Lines changed: 288 additions & 21 deletions

File tree

app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitBadgeScanApiController.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ public function __construct
9393
function getAddValidationRules(array $payload): array
9494
{
9595
return [
96-
'qr_code' => 'required|string',
96+
'qr_code' => 'required_without:attendee_email|string',
97+
'attendee_email' => 'required_without:qr_code|email',
9798
'scan_date' => 'required|date_format:U|epoch_seconds',
9899
'notes' => 'sometimes|string|max:1024',
99100
'extra_questions' => 'sometimes|extra_question_dto_array',

app/ModelSerializers/Summit/Registration/SponsorBadgeScanCSVSerializer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ final class SponsorBadgeScanCSVSerializer extends AbstractSerializer
3535
'SponsorId' => 'sponsor_id:json_int',
3636
'UserId' => 'scanned_by_id:json_int',
3737
'BadgeId' => 'badge_id:json_int',
38+
'Source' => 'source:json_string',
3839
'AttendeeFirstName' => 'attendee_first_name:json_string',
3940
'AttendeeLastName' => 'attendee_last_name:json_string',
4041
'AttendeeEmail' => 'attendee_email:json_string',

app/ModelSerializers/Summit/Registration/SponsorBadgeScanSerializer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ final class SponsorBadgeScanSerializer extends SilverStripeSerializer
2828
'SponsorId' => 'sponsor_id:json_int',
2929
'UserId' => 'scanned_by_id:json_int',
3030
'BadgeId' => 'badge_id:json_int',
31-
'Notes' => 'notes:json_string'
31+
'Notes' => 'notes:json_string',
32+
'Source' => 'source:json_string',
3233
];
3334

3435
protected static $allowed_relations = [

app/Models/Foundation/Summit/Registration/SponsorBadgeScan.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Doctrine\Common\Collections\ArrayCollection;
2020
use Doctrine\Common\Collections\Criteria;
2121
use Illuminate\Support\Facades\Log;
22+
use models\exceptions\ValidationException;
2223
use models\main\Member;
2324
use models\utils\One2ManyPropertyTrait;
2425
use Doctrine\ORM\Mapping AS ORM;
@@ -82,6 +83,15 @@ class SponsorBadgeScan extends SponsorUserInfoGrant
8283
#[ORM\Column(name: 'Notes', type: 'string')]
8384
private $notes;
8485

86+
87+
public const Source_QR = 'QRCode';
88+
public const Source_Attendee_Email = 'AttendeeEmail';
89+
90+
public const ValidSources = [self::Source_QR, self::Source_Attendee_Email];
91+
92+
#[ORM\Column(name: 'Source', type: 'string', options: ['default' => self::Source_QR])]
93+
private $source;
94+
8595
/**
8696
* @var SponsorBadgeScanExtraQuestionAnswer[]
8797
*/
@@ -91,6 +101,7 @@ class SponsorBadgeScan extends SponsorUserInfoGrant
91101
public function __construct()
92102
{
93103
parent::__construct();
104+
$this->source = self::Source_QR;
94105
$this->extra_question_answers = new ArrayCollection();
95106
}
96107

@@ -304,4 +315,13 @@ public function getScanByNice():string{
304315
if(!$this->hasUser()) return "TBD";
305316
return sprintf("%s (%s)",$this->user->getFullName(), $this->user->getEmail());
306317
}
307-
}
318+
319+
public function setSource(string $source): void{
320+
if(!in_array($source, self::ValidSources)) throw new ValidationException("Invalid source.");
321+
$this->source = $source;
322+
}
323+
324+
public function getSource(): string{
325+
return $this->source;
326+
}
327+
}

app/Services/Model/Imp/SponsorUserInfoGrantService.php

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use models\exceptions\ValidationException;
2323
use models\main\Member;
2424
use models\summit\ISponsorUserInfoGrantRepository;
25+
use models\summit\ISummitAttendeeRepository;
2526
use models\summit\SponsorBadgeScan;
2627
use models\summit\SponsorUserInfoGrant;
2728
use models\summit\Summit;
@@ -45,21 +46,29 @@ final class SponsorUserInfoGrantService
4546
*/
4647
private $badge_repository;
4748

49+
/**
50+
* @var ISummitAttendeeRepository
51+
*/
52+
private $attendee_repository;
53+
4854
/**
4955
* SponsorBadgeScanService constructor.
5056
* @param ISponsorUserInfoGrantRepository $repository
57+
* @param ISummitAttendeeRepository $attendee_repository
5158
* @param ISummitAttendeeBadgeRepository $badge_repository
5259
* @param ITransactionService $tx_service
5360
*/
5461
public function __construct
5562
(
5663
ISponsorUserInfoGrantRepository $repository,
64+
ISummitAttendeeRepository $attendee_repository,
5765
ISummitAttendeeBadgeRepository $badge_repository,
5866
ITransactionService $tx_service
5967
)
6068
{
6169
parent::__construct($tx_service);
6270
$this->repository = $repository;
71+
$this->attendee_repository = $attendee_repository;
6372
$this->badge_repository = $badge_repository;
6473
}
6574

@@ -104,10 +113,53 @@ public function addGrant(Summit $summit, int $sponsor_id, Member $current_member
104113
public function addBadgeScan(Summit $summit, Member $current_member, array $data): SponsorBadgeScan
105114
{
106115
return $this->tx_service->transaction(function() use($summit, $current_member, $data){
116+
$raw_qr_code = $data['qr_code'] ?? null;
117+
$raw_attendee_email = $data['attendee_email'] ?? null;
118+
if(empty($raw_qr_code) && empty($raw_attendee_email))
119+
throw new ValidationException("Missing required parameters (qr_code or attendee_email).");
120+
$ticket_number = null;
121+
$qr_code = null;
122+
$source = null;
123+
if(!empty($raw_qr_code)) {
124+
$qr_code = SummitAttendeeBadge::decodeQRCodeFor($summit, $raw_qr_code);
125+
$fields = SummitAttendeeBadge::parseQRCode($qr_code);
126+
$prefix = $fields['prefix'];
127+
if($summit->getBadgeQRPrefix() != $prefix)
128+
throw new ValidationException
129+
(
130+
sprintf
131+
(
132+
"%s qr code is not valid for summit %s.",
133+
$qr_code,
134+
$summit->getId()
135+
)
136+
);
137+
$ticket_number = $fields['ticket_number'];
138+
$source = SponsorBadgeScan::Source_QR;
139+
}
140+
else if(!empty($raw_attendee_email)) {
141+
if(!$current_member->isAdmin())
142+
throw new ValidationException("User should have admin rights.");
143+
144+
$attendee = $this->attendee_repository->getBySummitAndEmail($summit, trim($raw_attendee_email));
145+
if(is_null($attendee)){
146+
throw new EntityNotFoundException("Attendee not found.");
147+
}
148+
$ticket = null;
149+
foreach ($attendee->getTickets() as $t) {
150+
if ($t->isActive() && $t->hasBadge()) { $ticket = $t; break; }
151+
}
152+
153+
if(is_null($ticket)){
154+
throw new EntityNotFoundException("Ticket not found.");
155+
}
156+
$ticket_number = $ticket->getNumber();
157+
$badge = $ticket->getBadge();
158+
// normalize qr code
159+
$qr_code = SummitAttendeeBadge::decodeQRCodeFor($summit, $badge->getQRCode());
160+
$source = SponsorBadgeScan::Source_Attendee_Email;
161+
}
107162

108-
$qr_code = SummitAttendeeBadge::decodeQRCodeFor($summit, $data['qr_code']);
109-
$fields = SummitAttendeeBadge::parseQRCode($qr_code);
110-
$prefix = $fields['prefix'];
111163
$scan_date_epoch = intval($data['scan_date']);
112164
$scan_date = new \DateTime("@$scan_date_epoch");
113165
$begin_date = $summit->getBeginDate();
@@ -117,24 +169,14 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data
117169
if(!($scan_date >= $begin_date && $scan_date <= $end_date))
118170
throw new ValidationException("scan_date is does not belong to summit period.");
119171
*/
120-
121-
if($summit->getBadgeQRPrefix() != $prefix)
122-
throw new ValidationException
123-
(
124-
sprintf
125-
(
126-
"%s qr code is not valid for summit %s",
127-
$qr_code,
128-
$summit->getId()
129-
)
130-
);
131-
132-
$ticket_number = $fields['ticket_number'];
172+
if(empty($ticket_number)){
173+
throw new ValidationException("Ticket not found.");
174+
}
133175

134176
$badge = $this->badge_repository->getBadgeByTicketNumber($ticket_number);
135177

136178
if(is_null($badge))
137-
throw new EntityNotFoundException("badge not found");
179+
throw new EntityNotFoundException("badge not found.");
138180

139181
$sponsor = $current_member->getSponsorBySummit($summit);
140182

@@ -146,6 +188,7 @@ public function addBadgeScan(Summit $summit, Member $current_member, array $data
146188
$scan->setQRCode($qr_code);
147189
$scan->setUser($current_member);
148190
$scan->setBadge($badge);
191+
$scan->setSource($source);
149192
$scan->setNotes(isset($data['notes'])? trim($data['notes']): "");
150193

151194
$sponsor->addUserInfoGrant($scan);
@@ -228,4 +271,4 @@ public function getBadgeScan(Summit $summit, Member $current_member, int $scan_i
228271
return $scan;
229272
});
230273
}
231-
}
274+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php namespace Database\Migrations\Model;
2+
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use Doctrine\DBAL\Schema\Schema;
17+
use Doctrine\Migrations\AbstractMigration;
18+
use LaravelDoctrine\Migrations\Schema\Builder;
19+
use LaravelDoctrine\Migrations\Schema\Table;
20+
21+
/**
22+
* Class Version20260407003923
23+
* @package Database\Migrations\Model
24+
*/
25+
final class Version20260407003923 extends AbstractMigration
26+
{
27+
public function getDescription(): string
28+
{
29+
return "Add Source column to SponsorBadgeScan table with default 'QRCode'.";
30+
}
31+
32+
public function up(Schema $schema): void
33+
{
34+
$builder = new Builder($schema);
35+
if ($schema->hasTable("SponsorBadgeScan") && !$builder->hasColumn("SponsorBadgeScan", "Source")) {
36+
$builder->table("SponsorBadgeScan", function (Table $table) {
37+
$table->string('Source')
38+
->setNotnull(true)
39+
->setLength(255)
40+
->setDefault('QRCode');
41+
});
42+
}
43+
}
44+
45+
public function down(Schema $schema): void
46+
{
47+
$builder = new Builder($schema);
48+
if ($schema->hasTable("SponsorBadgeScan") && $builder->hasColumn("SponsorBadgeScan", "Source")) {
49+
$builder->table("SponsorBadgeScan", function (Table $table) {
50+
$table->dropColumn('Source');
51+
});
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)