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
58 changes: 58 additions & 0 deletions app/Models/Foundation/Summit/Registration/SponsorBadgeScan.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ class SponsorBadgeScan extends SponsorUserInfoGrant
#[ORM\Column(name: 'Source', type: 'string', options: ['default' => self::Source_QR])]
private $source;

/**
* Denormalized "<sponsor>:<badge>:<scan_date epoch>" identity of the physical
* scan this row represents, carrying a UNIQUE index (see the migration that
* adds SponsorBadgeScan_ScanDedupKey). That index - not the Redis dedup lock
* in SponsorUserInfoGrantService::addBadgeScanLocked - is what actually makes
* addBadgeScan idempotent: a lock with a TTL and no renewal cannot guarantee
* mutual exclusion (it can expire mid-transaction, and LockManagerService
* only logs the mismatch at release time), so the database has to be the
* authority on "one row per scan".
*
* The column has to live here rather than being an index over the tuple
* itself: SponsorUserInfoGrant/SponsorBadgeScan is a JOINED inheritance pair
* with SponsorID on the parent table and BadgeID/ScanDate on this one, and a
* UNIQUE index cannot span both tables.
*
* Nullable on purpose, and rows created before that migration keep NULL:
* MySQL allows any number of NULLs in a UNIQUE index, so pre-existing
* duplicates (which this bug already produced in production) neither block
* the index creation nor need deleting. Those historical rows stay covered by
* the explicit findExistingBadgeScan() check, which matches on the real
* columns; every new row gets a key and is covered by the index too.
* @var string|null
*/
#[ORM\Column(name: 'ScanDedupKey', type: 'string', nullable: true)]
private $scan_dedup_key;

/**
* @var SponsorBadgeScanExtraQuestionAnswer[]
*/
Expand Down Expand Up @@ -169,6 +195,38 @@ public function setScanDate(\DateTime $scan_date): void
$this->scan_date = $scan_date;
}

/**
* Builds the value for the ScanDedupKey UNIQUE index from the tuple that
* identifies one physical scan. Uses the scan_date's epoch so the key is
* insensitive to how the DateTime was constructed (timezone, sub-second
* precision the DATETIME column would drop anyway) - the scanning app
* sends the timestamp as epoch seconds and resends it unchanged on a retry.
* @param Sponsor $sponsor
* @param SummitAttendeeBadge $badge
* @param \DateTime $scan_date
* @return string
*/
public static function buildDedupKey(Sponsor $sponsor, SummitAttendeeBadge $badge, \DateTime $scan_date): string
{
return sprintf('%d:%d:%d', $sponsor->getId(), $badge->getId(), $scan_date->getTimestamp());
}

/**
* @return string|null
*/
public function getScanDedupKey(): ?string
{
return $this->scan_dedup_key;
}

/**
* @param string $scan_dedup_key
*/
public function setScanDedupKey(string $scan_dedup_key): void
{
$this->scan_dedup_key = $scan_dedup_key;
}

public function getAttendeeFirstName():?string{
$attendee = $this->getBadge()->getTicket()->getOwner();
return $attendee->hasMember() ? $attendee->getMember()->getFirstName() : $attendee->getFirstName();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,14 @@
*/
interface ISponsorUserInfoGrantRepository extends IBaseRepository
{

/**
* Looks up a previously persisted SponsorBadgeScan for the exact same
* (sponsor, badge, scan_date) tuple, used to make SponsorUserInfoGrantService::addBadgeScan
* idempotent against a client retry of the same scan (SUP-86b9fp53j).
* @param Sponsor $sponsor
* @param SummitAttendeeBadge $badge
* @param \DateTime $scan_date
* @return SponsorBadgeScan|null
*/
public function findExistingBadgeScan(Sponsor $sponsor, SummitAttendeeBadge $badge, \DateTime $scan_date): ?SponsorBadgeScan;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
use Doctrine\ORM\QueryBuilder;
use models\summit\ISponsorUserInfoGrantRepository;
use models\summit\Presentation;
use models\summit\Sponsor;
use models\summit\SponsorBadgeScan;
use models\summit\SponsorUserInfoGrant;
use models\summit\SummitAttendeeBadge;
use models\summit\SummitEvent;
use utils\DoctrineFilterMapping;
use utils\DoctrineInstanceOfFilterMapping;
Expand Down Expand Up @@ -124,4 +126,32 @@ protected function getBaseEntity()
{
return SponsorUserInfoGrant::class;
}
}

/**
* Queries SponsorBadgeScan directly (not the generic filter/order pipeline
* above, which matches against the whole SponsorUserInfoGrant hierarchy and
* is meant for paged listing) for an exact (sponsor, badge, scan_date) match.
* Doctrine resolves the SponsorUserInfoGrant/SponsorBadgeScan joined-table
* inheritance transparently, so no manual join is needed here.
* @param Sponsor $sponsor
* @param SummitAttendeeBadge $badge
* @param \DateTime $scan_date
* @return SponsorBadgeScan|null
*/
public function findExistingBadgeScan(Sponsor $sponsor, SummitAttendeeBadge $badge, \DateTime $scan_date): ?SponsorBadgeScan
{
$query = $this->getEntityManager()
->createQueryBuilder()
->select("e")
->from(SponsorBadgeScan::class, "e")
->where("e.sponsor = :sponsor")
->andWhere("e.badge = :badge")
->andWhere("e.scan_date = :scan_date")
->setParameter("sponsor", $sponsor)
->setParameter("badge", $badge)
->setParameter("scan_date", $scan_date)
->setMaxResults(1);

return $query->getQuery()->getOneOrNullResult();
}
}
Loading
Loading