diff --git a/public/app.js b/public/app.js index 3e9dda9..cbb54cc 100644 --- a/public/app.js +++ b/public/app.js @@ -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')); }); }; }; diff --git a/src/Controller/InviteController.php b/src/Controller/InviteController.php index 2cfd158..a2762d8 100644 --- a/src/Controller/InviteController.php +++ b/src/Controller/InviteController.php @@ -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); } diff --git a/src/Repository/InviteRepository.php b/src/Repository/InviteRepository.php index 0a926a5..620f7b5 100644 --- a/src/Repository/InviteRepository.php +++ b/src/Repository/InviteRepository.php @@ -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 { diff --git a/tests/AppIntegrationTest.php b/tests/AppIntegrationTest.php index 50719f3..067a5bc 100644 --- a/tests/AppIntegrationTest.php +++ b/tests/AppIntegrationTest.php @@ -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');