fix matching if somebody withdraws and dont rematch everybody!

This commit is contained in:
red 2025-12-05 11:40:09 +00:00
parent 97aaaf1b85
commit b62bf6fb34
6 changed files with 101 additions and 100 deletions

View File

@ -2,15 +2,18 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class Participant extends Model class Participant extends Model
{ {
use HasFactory; use HasFactory;
use SoftDeletes;
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
@ -20,18 +23,6 @@ class Participant extends Model
'token', 'token',
]; ];
protected $appends = ['desperate'];
public function receiver() : belongsTo
{
return $this->belongsTo(Participant::class, 'giving_id');
}
public function giver(): hasOne
{
return $this->hasOne(Participant::class, 'giving_id', 'id');
}
public static function findByToken(string $token): ?self public static function findByToken(string $token): ?self
{ {
return self::where('token', $token)->first(); return self::where('token', $token)->first();
@ -69,16 +60,60 @@ class Participant extends Model
->whereDoesntHave('receiver'); ->whereDoesntHave('receiver');
} }
public function getDesperateAttribute(): bool public function receiver(): BelongsTo
{ {
$gives = !is_null($this->giver); return $this->belongsTo(Participant::class, 'giving_id');
$gets = !is_null($this->receiver); }
return ($gives && !$gets) || (!$gives && $gets); public function giver(): HasOne
{
return $this->hasOne(Participant::class, 'giving_id', 'id');
}
public function hasReceiver(): bool
{
return !is_null($this->giving_id); // The simplest and most direct check for an outgoing link
}
public function hasGiver(): bool
{
return $this->hasOne(Participant::class, 'giving_id', 'id')
->exists();
}
protected function isUnmatched(): Attribute
{
return Attribute::make(
get: fn () => !$this->hasReceiver() && !$this->hasGiver(),
);
}
protected function isDesperate(): Attribute
{
return Attribute::make(
get: fn () => $this->hasReceiver() xor $this->hasGiver(),
);
} }
public function withdraw(): bool public function withdraw(): bool
{ {
if (!$this->id) {
return false; return false;
} }
$giver = Participant::where('giving_id', $this->id)->first();
if ($giver) {
$giver->giving_id = null;
$giver->save();
}
$userB_id = $this->giving_id;
if ($userB_id) {
$this->giving_id = null;
}
$this->delete();
return true;
}
} }

View File

@ -14,92 +14,57 @@ class MatcherService
return; return;
} }
// desperate denotes a user A that is supposed to give to a user B that dropped out
// whilst user A has a user C giving to them, or vice versa
$desperate = $participants->where('desperate', true)->values();
$nonDesperate = $participants->where('desperate', false)->values();
$desperate = $desperate->shuffle();
$nonDesperate = $nonDesperate->shuffle();
$assignments = []; $assignments = [];
/* $newParticipantIds = $participants
|-------------------------------------------------------------------------- ->filter(fn($p) => $p->is_unmatched)
| 1. Match DESPERATE participants ->pluck('id')
|-------------------------------------------------------------------------- ->shuffle()
| A desperate user should match: ->all();
| - First to another desperate participant
| - If none left, match to a non-desperate participant
|--------------------------------------------------------------------------
*/
foreach ($desperate as $p) { $desperateGiverIds = $participants
// Exclude self from possible options ->filter(fn($p) => $p->is_desperate && !$p->hasReceiver())
$pool = $desperate->filter(fn($d) => $d->id !== $p->id); ->pluck('id')
->shuffle()
->all();
if ($pool->isEmpty()) { $desperateReceiverIds = $participants
$pool = $nonDesperate; ->filter(fn($p) => $p->is_desperate && !$p->hasGiver())
} ->pluck('id')
->shuffle()
->all();
if ($pool->isEmpty()) { while (!empty($desperateGiverIds) && !empty($desperateReceiverIds)) {
$giverId = array_shift($desperateGiverIds);
$receiverId = array_shift($desperateReceiverIds);
if ($giverId === $receiverId) {
array_unshift($desperateReceiverIds, $receiverId);
continue; continue;
} }
$candidate = $this->pickValidTarget($p, $pool, $assignments); $assignments[$giverId] = $receiverId;
if ($candidate) { }
$assignments[$p->id] = $candidate->id;
$newCount = count($newParticipantIds);
if ($newCount >= 2) {
for ($i = 0; $i < $newCount; $i++) {
$currentId = $newParticipantIds[$i];
$nextId = $newParticipantIds[($i + 1) % $newCount];
$assignments[$currentId] = $nextId;
} }
} }
/*
|--------------------------------------------------------------------------
| 2. Match NON-DESPERATE participants
|--------------------------------------------------------------------------
| A non-desperate participant always matches to the non-desperate pool.
|--------------------------------------------------------------------------
*/
foreach ($nonDesperate as $p) { foreach ($assignments as $giverId => $receiverId) {
// Exclude self Participant::where('id', $giverId)->update([
$pool = $nonDesperate->filter(fn($d) => $d->id !== $p->id); 'giving_id' => $receiverId,
if ($pool->isEmpty()) {
continue;
}
$candidate = $this->pickValidTarget($p, $pool, $assignments);
if ($candidate) {
$assignments[$p->id] = $candidate->id;
}
}
/*
|--------------------------------------------------------------------------
| 3. Write all results back to DB
|--------------------------------------------------------------------------
*/
foreach ($assignments as $id => $givingId) {
Participant::where('id', $id)->update([
'giving_id' => $givingId,
]); ]);
} }
} }
private function pickValidTarget(Participant $giver, Collection $pool, array $assignments): ?Participant
{
$shuffled = $pool->shuffle();
// Avoid duplicates
foreach ($shuffled as $candidate) {
if (($assignments[$candidate->id] ?? null) === $giver->id) {
continue;
}
return $candidate;
}
return null;
}
} }

View File

@ -24,6 +24,7 @@ return new class extends Migration
$table->string('token'); $table->string('token');
$table->timestamps(); $table->timestamps();
$table->softDeletes();
}); });
} }

View File

@ -27,7 +27,7 @@ rules([
], ],
]); ]);
mount(function ($token): null mount(function ($token)
{ {
$this->token = $token; $this->token = $token;
if ($this->token) { if ($this->token) {
@ -51,7 +51,7 @@ mount(function ($token): null
} }
}); });
$saveEditingPrompt = function (): null $saveEditingPrompt = function ()
{ {
if ($this->participant) { if ($this->participant) {
$this->validate(); $this->validate();
@ -62,13 +62,13 @@ $saveEditingPrompt = function (): null
} }
}; };
$cancelEditingPrompt = function (): null $cancelEditingPrompt = function ()
{ {
$this->prompt = $this->participant->prompt ?? ''; $this->prompt = $this->participant->prompt ?? '';
$this->isEditingPrompt = false; $this->isEditingPrompt = false;
}; };
$saveEditingSubmission = function (): null $saveEditingSubmission = function ()
{ {
if ($this->participant) { if ($this->participant) {
$this->validate(); $this->validate();
@ -79,7 +79,7 @@ $saveEditingSubmission = function (): null
} }
}; };
$cancelEditingSubmission = function (): null $cancelEditingSubmission = function ()
{ {
$this->submissionUrl = $this->participant->submission_url ?? ''; $this->submissionUrl = $this->participant->submission_url ?? '';
$this->isEditingSubmission = false; $this->isEditingSubmission = false;

View File

@ -88,8 +88,8 @@ $runMatch = function () {
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{{ $participant->desperate ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' }}"> {{ $participant->is_desperate ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' }}">
{{ $participant->desperate ? 'YES' : 'No' }} {{ $participant->is_desperate ? 'YES' : 'No' }}
</span> </span>
</td> </td>

View File

@ -12,7 +12,7 @@ state([
'participant' => null, 'participant' => null,
]); ]);
mount(function ($token): null mount(function ($token)
{ {
$this->token = $token; $this->token = $token;
if ($this->token) { if ($this->token) {
@ -20,7 +20,7 @@ mount(function ($token): null
} }
}); });
$confirmWithdrawal = function (): null $confirmWithdrawal = function ()
{ {
$this->js(<<<'JS' $this->js(<<<'JS'
if (confirm('Are you sure you want to withdraw from the event? This action cannot be undone.')) { if (confirm('Are you sure you want to withdraw from the event? This action cannot be undone.')) {
@ -29,7 +29,7 @@ $confirmWithdrawal = function (): null
JS); JS);
}; };
$withdraw = function (): mixed $withdraw = function ()
{ {
if($this->participant->withdraw()) { if($this->participant->withdraw()) {
$this->js('alert("Withdrawal complete")'); $this->js('alert("Withdrawal complete")');