Skip to content
62 changes: 62 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>:<member>:<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,42 @@ 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.
* The scanning member is part of the identity: a real retry always comes from
* the same member, whereas two reps of one sponsor scanning the same badge in
* the same second are two distinct scans, each with its own notes and answers.
* @param Sponsor $sponsor
* @param SummitAttendeeBadge $badge
* @param Member $member
* @param \DateTime $scan_date
* @return string
*/
public static function buildDedupKey(Sponsor $sponsor, SummitAttendeeBadge $badge, Member $member, \DateTime $scan_date): string
{
return sprintf('%d:%d:%d:%d', $sponsor->getId(), $badge->getId(), $member->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 @@ -11,12 +11,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use models\main\Member;
use models\utils\IBaseRepository;
/**
* Interface ISponsorUserInfoGrantRepository
* @package models\summit
*/
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, Member $member, \DateTime $scan_date): ?SponsorBadgeScan;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
**/
use App\Repositories\SilverStripeDoctrineRepository;
use Doctrine\ORM\QueryBuilder;
use models\main\Member;
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 +127,35 @@ 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, member, 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 Member $member
* @param \DateTime $scan_date
* @return SponsorBadgeScan|null
*/
public function findExistingBadgeScan(Sponsor $sponsor, SummitAttendeeBadge $badge, Member $member, \DateTime $scan_date): ?SponsorBadgeScan
{
$query = $this->getEntityManager()
->createQueryBuilder()
->select("e")
->from(SponsorBadgeScan::class, "e")
->where("e.sponsor = :sponsor")
->andWhere("e.badge = :badge")
->andWhere("e.user = :member")
->andWhere("e.scan_date = :scan_date")
->setParameter("sponsor", $sponsor)
->setParameter("badge", $badge)
->setParameter("member", $member)
->setParameter("scan_date", $scan_date)
->setMaxResults(1);

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