Invite improvements: cap pending at 10, sort by status, hide old expired

- Max 10 pending invites per user (400 if exceeded)
- List sorted: pending → used → expired
- Expired invites hidden after 30 days
- Frontend shows error toast from server message on invite creation failure
- Tests: testInviteMaxTenPending, testInviteListSortOrder, testInviteExpiredHiddenAfter30Days

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kühn 2026-05-01 10:22:16 +02:00
parent 950c3bcfc5
commit c9e8f69c3f
4 changed files with 107 additions and 7 deletions

View file

@ -560,9 +560,9 @@ function openData(){
navigator.clipboard.writeText(res.url).then(function(){ showToast(tr('linkCopied')); closeOv(); });
};
setTimeout(function(){urlInp.select();},50);
}).catch(function(){
}).catch(function(err){
btn.disabled=false; btn.textContent=tr('generateLink');
showToast(tr('errGenerate'));
showToast(err.message||tr('errGenerate'));
});
};
};

View file

@ -28,6 +28,10 @@ class InviteController extends AbstractController
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
if ($this->invites->countPendingByCreator($user->getId()) >= 10) {
return new JsonResponse(['error' => 'Maximal 10 offene Einladungen erlaubt'], Response::HTTP_BAD_REQUEST);
}
$data = json_decode($request->getContent(), true) ?? [];
$note = trim($data['note'] ?? '') ?: null;
$token = bin2hex(random_bytes(32));
@ -53,11 +57,13 @@ class InviteController extends AbstractController
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$rows = $this->invites->findByCreator($user->getId());
$rows = $this->invites->findByCreator($user->getId());
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
$cutoff = new \DateTimeImmutable('-30 days');
$result = array_map(function (Invite $inv) use ($appUrl) {
$pending = $inv->isPending();
$result = [];
foreach ($rows as $inv) {
$pending = $inv->isPending();
$usedByEmail = null;
if ($inv->getUsedBy()) {
$usedByEmail = $this->em->getConnection()->fetchOne(
@ -74,7 +80,11 @@ class InviteController extends AbstractController
$status = 'pending';
}
return [
if ($status === 'expired' && $inv->getExpiresAt() < $cutoff) {
continue;
}
$result[] = [
'url' => $status === 'pending' ? $appUrl . '/?invite=' . $inv->getToken() : null,
'created_at' => $inv->getCreatedAt()->format('Y-m-d H:i:s'),
'expires_at' => $inv->getExpiresAt()->format('Y-m-d H:i:s'),
@ -83,7 +93,10 @@ class InviteController extends AbstractController
'used_by_email' => $usedByEmail,
'status' => $status,
];
}, $rows);
}
$order = ['pending' => 0, 'used' => 1, 'expired' => 2];
usort($result, fn($a, $b) => $order[$a['status']] <=> $order[$b['status']]);
return new JsonResponse($result);
}

View file

@ -25,6 +25,19 @@ class InviteRepository extends ServiceEntityRepository
->getOneOrNullResult();
}
public function countPendingByCreator(int $userId): int
{
return (int) $this->createQueryBuilder('i')
->select('COUNT(i.id)')
->where('i.createdBy = :userId')
->andWhere('i.usedBy IS NULL')
->andWhere('i.expiresAt > :now')
->setParameter('userId', $userId)
->setParameter('now', new \DateTimeImmutable())
->getQuery()
->getSingleScalarResult();
}
/** @return Invite[] */
public function findByCreator(int $userId): array
{

View file

@ -724,6 +724,80 @@ class AppIntegrationTest extends WebTestCase
$this->assertNull($invites[0]->getNote());
}
public function testInviteMaxTenPending(): void
{
$user = $this->createUser('invmax');
$client = $this->authClient($user);
for ($i = 0; $i < 10; $i++) {
$inv = new Invite();
$inv->setToken(bin2hex(random_bytes(32)))
->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($inv);
}
$this->em->flush();
$data = $this->json($client, 'POST', '/api/invite', ['note' => 'over limit']);
$this->assertArrayHasKey('error', $data);
$this->assertSame(400, $client->getResponse()->getStatusCode());
}
public function testInviteListSortOrder(): void
{
$user = $this->createUser('invsort');
$usedBy = $this->createUser('invsortu');
$pending = new Invite();
$pending->setToken(bin2hex(random_bytes(32)))->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$expired = new Invite();
$expired->setToken(bin2hex(random_bytes(32)))->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('-1 day'));
$used = new Invite();
$used->setToken(bin2hex(random_bytes(32)))->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'))
->setUsedBy($usedBy->getId())->setUsedAt(new \DateTimeImmutable());
$this->em->persist($expired);
$this->em->persist($used);
$this->em->persist($pending);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/invites');
$this->assertSame('pending', $data[0]['status']);
$this->assertSame('used', $data[1]['status']);
$this->assertSame('expired', $data[2]['status']);
}
public function testInviteExpiredHiddenAfter30Days(): void
{
$user = $this->createUser('invexpold');
$old = new Invite();
$old->setToken(bin2hex(random_bytes(32)))->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('-31 days'));
$recent = new Invite();
$recent->setToken(bin2hex(random_bytes(32)))->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('-5 days'));
$this->em->persist($old);
$this->em->persist($recent);
$this->em->flush();
$client = $this->authClient($user);
$data = $this->json($client, 'GET', '/api/invites');
$this->assertCount(1, $data);
$this->assertSame('expired', $data[0]['status']);
}
public function testInviteListIsolatedFromOtherUsers(): void
{
$user1 = $this->createUser('inviso1');