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:
parent
950c3bcfc5
commit
c9e8f69c3f
4 changed files with 107 additions and 7 deletions
|
|
@ -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'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue