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(); }); navigator.clipboard.writeText(res.url).then(function(){ showToast(tr('linkCopied')); closeOv(); });
}; };
setTimeout(function(){urlInp.select();},50); setTimeout(function(){urlInp.select();},50);
}).catch(function(){ }).catch(function(err){
btn.disabled=false; btn.textContent=tr('generateLink'); 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); 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) ?? []; $data = json_decode($request->getContent(), true) ?? [];
$note = trim($data['note'] ?? '') ?: null; $note = trim($data['note'] ?? '') ?: null;
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
@ -55,8 +59,10 @@ class InviteController extends AbstractController
$rows = $this->invites->findByCreator($user->getId()); $rows = $this->invites->findByCreator($user->getId());
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost'; $appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
$cutoff = new \DateTimeImmutable('-30 days');
$result = array_map(function (Invite $inv) use ($appUrl) { $result = [];
foreach ($rows as $inv) {
$pending = $inv->isPending(); $pending = $inv->isPending();
$usedByEmail = null; $usedByEmail = null;
if ($inv->getUsedBy()) { if ($inv->getUsedBy()) {
@ -74,7 +80,11 @@ class InviteController extends AbstractController
$status = 'pending'; $status = 'pending';
} }
return [ if ($status === 'expired' && $inv->getExpiresAt() < $cutoff) {
continue;
}
$result[] = [
'url' => $status === 'pending' ? $appUrl . '/?invite=' . $inv->getToken() : null, 'url' => $status === 'pending' ? $appUrl . '/?invite=' . $inv->getToken() : null,
'created_at' => $inv->getCreatedAt()->format('Y-m-d H:i:s'), 'created_at' => $inv->getCreatedAt()->format('Y-m-d H:i:s'),
'expires_at' => $inv->getExpiresAt()->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, 'used_by_email' => $usedByEmail,
'status' => $status, '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); return new JsonResponse($result);
} }

View file

@ -25,6 +25,19 @@ class InviteRepository extends ServiceEntityRepository
->getOneOrNullResult(); ->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[] */ /** @return Invite[] */
public function findByCreator(int $userId): array public function findByCreator(int $userId): array
{ {

View file

@ -724,6 +724,80 @@ class AppIntegrationTest extends WebTestCase
$this->assertNull($invites[0]->getNote()); $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 public function testInviteListIsolatedFromOtherUsers(): void
{ {
$user1 = $this->createUser('inviso1'); $user1 = $this->createUser('inviso1');