Admin (founder) pozve konkrétního zájemce z waitlistu — vygeneruje founder-direct invite token, automaticky odešle e-mail s odkazem na /join?token=... přes Mailgun a označí waitlist entry jako pozvanou. Vyžaduje roli ADMIN.
- Jedná se o rozšíření existujícího invite systému (UC-08003) — vytváří se founder-direct token (
issued_by_user_id = NULL,issued_to_email = e-mail z waitlist entry) stejnou logikou jakoGenerateAdminInvitesUseCase. - Na rozdíl od
POST /admin/invites(batch, bez e-mailu, pro interní použití) tento endpoint posílá invite e-mail automaticky přesEmailDispatchService+ novýEmailType.WAITLIST_INVITE. - Idempotence (jeden endpoint, chování odvozené ze stavu invite — viz Stavový model níže):
- NEPOZVÁN (
invite_id = NULL) → vystaví nový token + odešle e-mail. - PENDING (aktivní token) → endpoint nevytváří nový token, místo toho odešle resend se STEJNÝM existujícím tokenem (stejný
/join?token=...),expires_atse NEMĚNÍ. Resend je throttlovaný (viz níže). - EXPIRED / REVOKED → re-invite: vystaví nový token + odešle e-mail (předchozí token je mrtvý).
- CLAIMED (e-mail registrovaný uživatel) → terminální stav, žádný resend ani re-invite (
409 Conflict).
- NEPOZVÁN (
- Resend throttle: max 1 odeslání e-mailu typu
WAITLIST_INVITEna danou adresu za 24 h. Princip je stejný jako u waitlist confirmation e-mailu (UC-11001) —EmailDispatchService.wasRecentlySent(type, recipient)čte posledníSENTzáznam zemail_loga porovnávácreated_atprotinow − windowHours. Při překročení vrací429 Too Many Requestss kódemINVITE_RESEND_THROTTLED. - Datový model: nové sloupce
invited_at+invite_idnawaitlist_entry— nový Liquibase changeset0029-add-invite-to-waitlist-entry.xml(číslo ověřit při rebase — paralelní týmy berou čísla). - Pokud e-mail z waitlist entry patří již registrovanému uživateli (
users.emailmatch), endpoint vrátí409 Conflicts kódemCONFLICT_ALREADY_REGISTERED— invite nelze vystavit na existující účet. - E-mail šablona
EmailTemplates.waitlistInvite(name, inviteUrl, lang)— nová šablona, cs + en, default EN, konzistentní s existujícími šablonami.
Datový model — rozšíření waitlist_entry
Nová Liquibase migrace 0029-add-invite-to-waitlist-entry.xml (PRODUCTION — nový soubor, neediovat existující):
ALTER TABLE waitlist_entry
ADD COLUMN invited_at TIMESTAMPTZ,
ADD COLUMN invite_id BIGINT REFERENCES invites(id);
invited_at— timestamp posledního pozvaní (nullable; NULL = zatím nepozván). Slouží pro zobrazení stavu v admin tabulce.invite_id— FK nainvites.id; nullable. Umožňuje BE ověřit aktuální stav posledního invite bez join-dotazu přes email. Po expiraci / revokaci zůstává hodnota zachována (historický záznam) — BE ověřujeinvites.statuspři každém re-invite pokusu.
Proč invited_at + invite_id místo enum status?
Jednodušší schema. invited_at stačí pro zobrazení stavu v UI (NULL = Nepozván; non-null = Pozván). Enum by vyžadoval přidávání hodnot (PENDING, EXPIRED, …) duplicitně k invites.status. invite_id FK dává přímý přístup k live stavu invite záznamu — jedno source of truth.
Stavový model pozvánky waitlist entry
Jeden endpoint POST /api/v1/admin/waitlist/{id}/invite obsluhuje celý životní cyklus. Konkrétní akce (nový token / resend / re-invite / odmítnutí) se odvozuje výhradně ze stavu navázaného invite (waitlist_entry.invite_id → invites.status).
stateDiagram-v2
[*] --> NEPOZVAN: waitlist entry vznikne (invite_id = NULL)
NEPOZVAN --> PENDING: POST invite — vystaví nový token + e-mail
PENDING --> PENDING: POST invite — RESEND stejného tokenu (throttle 1/24h)
PENDING --> EXPIRED: invites.expires_at < now (status = EXPIRED)
PENDING --> REVOKED: token zrušen (status = REVOKED)
PENDING --> CLAIMED: příjemce se zaregistruje (UC-08003 Flow 3)
EXPIRED --> PENDING: POST invite — RE-INVITE = nový token + e-mail
REVOKED --> PENDING: POST invite — RE-INVITE = nový token + e-mail
CLAIMED --> [*]: terminální — žádný resend ani re-invite
| Stav | invite_id / invites.status | POST .../invite chování |
|---|---|---|
| NEPOZVÁN | invite_id = NULL | Vystaví nový PENDING token, expires_at = now + 14d, odešle e-mail. → PENDING |
| PENDING | invite_id → status = PENDING | Resend STEJNÉHO tokenu (žádný nový INSERT, expires_at beze změny). Throttle 1/24h — překročení → 429 INVITE_RESEND_THROTTLED. Zůstává PENDING |
| EXPIRED | invite_id → status = EXPIRED (nebo expires_at < now) | Re-invite — vystaví nový PENDING token, přepíše invite_id, odešle e-mail. → PENDING |
| REVOKED | invite_id → status = REVOKED | Re-invite — vystaví nový PENDING token, přepíše invite_id, odešle e-mail. → PENDING |
| CLAIMED | e-mail v users (registrovaný) | Terminální. 409 CONFLICT_ALREADY_REGISTERED. Žádný resend/re-invite |
Implementation Note — detekce CLAIMED: Stav „registrován” se primárně detekuje lookupem
users.email = waitlist_entry.email(uživatel se mohl zaregistrovat i jiným tokenem nebo jiným kanálem). Sekundárně: navázaný invite sestatus = CLAIMED. Pokud platí kterékoli z toho → terminální stav. Lookup přesusers.emailje autoritativní, protožeissued_to_emailse při claimu nevaliduje proti skutečně použitému e-mailu (UC-08003).
Implementation Note — resend throttle source of truth: Throttle se počítá z
email_log(typWAITLIST_INVITE, příjemce = e-mail entry), NE zinvited_at.invited_atoznačuje datum prvního/posledního vystavení tokenu, kdežto throttle hlídá frekvenci odeslání e-mailu (resend tokenu neměníinvited_at). ReuseEmailDispatchService.wasRecentlySent(EmailType.WAITLIST_INVITE, email); throttle window navázat na novou propertytalkide.email.waitlist-invite-resend-window-hours(default 24) — konzistentní s existujícíwaitlist-confirmation-resend-window-hours.
Sekvence — Admin pozve zájemce z waitlistu
sequenceDiagram
actor Admin
Admin->>+FE: klikne "Pozvat" / "Poslat znovu" u řádku waitlist entry
FE->>FE: confirm? (volitelně — inline loading)
FE->>+BE: POST /api/v1/admin/waitlist/{id}/invite <br> Authorization: Bearer {token}
BE->>BE: ověř roli ADMIN
alt uživatel není ADMIN
BE-->>FE: 403 Forbidden <br> ErrorResponse
end
BE->>DB: SELECT waitlist_entry WHERE id = ?
alt entry neexistuje
BE-->>FE: 404 Not Found <br> ErrorResponse
end
BE->>DB: SELECT users WHERE email = waitlist_entry.email
alt email již registrovaný (CLAIMED — terminální)
BE-->>FE: 409 Conflict <br> ErrorResponse (CONFLICT_ALREADY_REGISTERED)
end
BE->>DB: SELECT invites WHERE id = waitlist_entry.invite_id
alt invite_id = NULL (NEPOZVÁN)
BE->>DB: INSERT invites <br> (token=UUID-v4, issued_by_user_id=NULL, <br> issued_to_email=entry.email, status=PENDING, <br> expires_at=now+14d)
BE->>DB: UPDATE waitlist_entry SET invited_at=now, invite_id={new id}
Note over BE: action = INVITED (nový token)
else status = EXPIRED nebo REVOKED (RE-INVITE)
BE->>DB: INSERT invites (nový token, expires_at=now+14d)
BE->>DB: UPDATE waitlist_entry SET invited_at=now, invite_id={new id}
Note over BE: action = REINVITED (nový token)
else status = PENDING (RESEND existujícího tokenu)
BE->>BE: EmailDispatchService.wasRecentlySent(WAITLIST_INVITE, email)
alt resend v posledních 24h (throttled)
BE-->>FE: 429 Too Many Requests <br> ErrorResponse (INVITE_RESEND_THROTTLED)
end
Note over BE: action = RESENT (STEJNÝ token, expires_at beze změny, invited_at beze změny)
end
BE->>BE: EmailTemplates.waitlistInvite(name, inviteUrl)
BE->>BE: EmailDispatchService.dispatch(WAITLIST_INVITE, email, ...) <br> (zápis do email_log — SENT/FAILED)
BE->>-FE: 201 Created (INVITED/REINVITED) <br> nebo 200 OK (RESENT) <br> WaitlistInviteResponse
FE->>-Admin: aktualizuj řádek v tabulce <br> (stav + toast dle action)
POST /api/v1/admin/waitlist/{id}/invite — tělo requestu není potřeba (vše se bere z waitlist entry). Jediný endpoint pro celý životní cyklus; konkrétní akce je odvozena ze stavu (Stavový model výše) a vrácena v poli action.
Implementation Note — HTTP status:
201 CreatedproINVITEDaREINVITED(vznikl novýinviteszáznam),200 OKproRESENT(žádný nový zdroj nevznikl, jen znovuodeslán e-mail). FE rozlišuje akci primárně přes poleaction, ne přes status kód.
Implementation Note — řazení kontrol: Throttle check (
wasRecentlySent) se provádí AŽ ve větvi PENDING (resend), NE pro INVITED/REINVITED — ty vždy vytvářejí nový token a vždy posílají e-mail (první e-mail nové pozvánky se nethrottluje). 24h okno se tedy uplatní jen na opakované resendy téhož aktivního tokenu.
201 Created / 200 OK WaitlistInviteResponse:
{
"data": {
"action": "RESENT",
"invite_id": 84,
"token": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"invite_url": "https://talkide.app/join?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"issued_to_email": "jane@example.com",
"expires_at": "2026-05-31T10:00:00Z",
"email_sent": true
}
}
action ∈ INVITED (nový token, NEPOZVÁN → PENDING) | REINVITED (nový token, EXPIRED/REVOKED → PENDING) | RESENT (stejný token, PENDING → PENDING). U RESENT jsou token, invite_url, expires_at totožné s původně vystaveným tokenem.
email_sent: false nastane pokud EmailDispatchService selže (best-effort — invite je vytvořen / dohledán, e-mail nebyl doručen). Admin vidí varování v UI. Pozn.: u RESENT selhání e-mailu NEspotřebuje throttle okno (throttle se počítá z SENT záznamů v email_log).
403 Forbidden ErrorResponse:
{
"code": "FORBIDDEN",
"message": "Admin role required"
}
404 Not Found ErrorResponse:
{
"code": "NOT_FOUND",
"message": "Waitlist entry not found"
}
429 Too Many Requests (resend throttled — PENDING token, e-mail odeslán v posledních 24 h) ErrorResponse:
{
"code": "INVITE_RESEND_THROTTLED",
"message": "An invite email was already sent recently. Try again later."
}
409 Conflict (e-mail již registrovaný — CLAIMED, terminální) ErrorResponse:
{
"code": "CONFLICT_ALREADY_REGISTERED",
"message": "This email is already registered"
}
E-mail šablona — waitlistInvite
Nová metoda v EmailTemplates.kt — konzistentní s existujícími šablonami (forgotPassword, waitlistConfirmation). Nová hodnota WAITLIST_INVITE v EmailType enumu.
EN (default)
| Subject | You're invited to TalkIDE |
| Text body | Hi {name}, you're off the waitlist — you've been invited to join TalkIDE. Use this link to create your account (valid for 14 days): {inviteUrl}. — TalkIDE |
| HTML body | Viz níže |
<p>Hi {name},</p>
<p>Great news — you're off the waitlist.</p>
<p>You've been personally invited to join TalkIDE.<br>
<a href="{inviteUrl}">Create your account →</a></p>
<p>This link is valid for <strong>14 days</strong>. After that, the invitation expires and you won't be able to use it.</p>
<p>— TalkIDE</p>
CS
| Subject | Byl(a) jste pozván(a) na TalkIDE |
| Text body | Ahoj {name}, je to tady — dostali jste se z waitlistu a máte osobní pozvánku na TalkIDE. Vytvořte si účet přes tento odkaz (platnost 14 dní): {inviteUrl}. — TalkIDE |
| HTML body | Viz níže |
<p>Ahoj {name},</p>
<p>Gratulujeme — dostali jste se z waitlistu.</p>
<p>Máte osobní pozvánku na TalkIDE.<br>
<a href="{inviteUrl}">Vytvořit účet →</a></p>
<p>Odkaz je platný <strong>14 dní</strong>. Po uplynutí platnosti pozvánka expiruje.</p>
<p>— TalkIDE</p>
UX Guidelines
Navigace — proklik na /admin/waitlist v UserMenu
Odkaz na /admin/waitlist přidat do UserMenu jako role-aware položku. Vzor: “silent probe” pattern použitý v InvitesSection.vue — při renderu UserMenu FE zavolá admin endpoint (např. GET /api/v1/admin/waitlist?page=0&size=1) a pokud vrátí 200, zobrazí položku; pokud 403, položku skryje. Tím nevystavuje admin route neadminům.
Alternativa (jednodušší): authStore.user.role === 'ADMIN' pokud role je dostupná v JWT/store. Preferuje se tato varianta pokud je role v auth store přítomna — méně HTTP volání.
Umístění v UserMenu: za položkou “Platform Issues”, před “Sign Out”.
UserMenu:
Profile
Billing
Platform Issues
Admin: Waitlist ← nová položka (jen pro role=ADMIN)
Sign Out
Tlačítko “Pozvat” v WaitlistTable
Nový sloupec “Stav” (nebo rozšíření existujícího sloupce) v tabulce waitlistu + tlačítko “Pozvat” u každého řádku.
Stavy sloupce:
| Stav entry | Zobrazení | Akce |
|---|---|---|
invite_id = NULL (NEPOZVÁN) | pill “Nepozván” (var(--bg-3) bg, var(--fg-3) text) | tlačítko “Pozvat” (.btn.ghost, Mail ikona 14px) |
| invite PENDING (POZVÁN) | pill “Pozván {dd.mm.}” (var(--amber-soft) bg, var(--amber) text) | tlačítko “Poslat znovu” (.btn.ghost, Send ikona 14px) — resend stejného tokenu |
| invite EXPIRED/REVOKED | pill “Expiroval {dd.mm.}” (var(--rose-soft) bg, var(--rose) text) | tlačítko “Pozvat znovu” (.btn.ghost) — re-invite (nový token) |
E-mail registrovaný v users (CLAIMED) | pill “Registrován” (var(--green-soft) bg, var(--green) text) | žádná akce — tlačítko skryté |
Pozn.: “Poslat znovu” (PENDING, resend) vs. “Pozvat znovu” (EXPIRED/REVOKED, re-invite) jsou vizuálně podobné, ale sémanticky odlišné — první neposouvá platnost, druhý vystaví nový token. Tooltip “Poslat znovu” doplnit popiskem “Stejný odkaz, platnost se nemění”.
Klik na “Pozvat” / “Poslat znovu” / “Pozvat znovu”:
- Tlačítko přejde do loading stavu (
SpinLoader, 18px, text “Odesílám…”). - FE volá
POST /api/v1/admin/waitlist/{id}/invite. - Po
201 Created/200 OK— rozliš dledata.action:INVITED/REINVITED: řádek → pill “Pozván {dnešní datum}”, akce přepne na “Poslat znovu”. Toast “Pozvánka odeslána na jane@example.com” (var(--green-soft)border,CheckCircle, auto-dismiss 4s).RESENT: pill zůstává “Pozván {původní datum}”. Toast “Pozvánka znovu odeslána na jane@example.com” (var(--green-soft)border).- Pokud
email_sent: false(kterákoli akce): Toast “Pozvánka {vytvořena/dohledána}, ale e-mail se nepodařilo odeslat.” (var(--amber)border,AlertTriangle). Admin může zkopírovat invite URL z inline rozbalení řádku.
- Po
429 INVITE_RESEND_THROTTLED: Toast “E-mail s pozvánkou byl nedávno odeslán. Zkus to za chvíli.” (var(--amber)border). Tlačítko “Poslat znovu” se na ~60 s zobrazí jako disabled s tooltipem “Limit 1 odeslání / 24 h”; tabulka se nemění. - Po
409 CONFLICT_ALREADY_REGISTERED: Řádek se aktualizuje na stav “Registrován”, toast “Tento e-mail je již zaregistrovaný.” (var(--fg-3)). - Po jiné chybě: Toast “Odeslání pozvánky selhalo.” s Retry odkazem.
Layout rozšíření WaitlistTable
Přidat sloupec “Stav pozvánky” vpravo od sloupce “Joined” a sloupec “Akce” úplně vpravo. Na < lg viewport schovej sloupec “Akce” za kontextové menu (tři tečky) u každého řádku.
| # | Email | Jméno | Role | Referraly | Joined | Stav pozvánky | Akce |
Frontend
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
{id} (path param) | positive integer | ^\d+$ | Číselné ID waitlist entry z URL; chybný formát → nevolat BE |
UX poznámky
- U řádku se stavem PENDING nezobrazovat disabled tlačítko, ale aktivní “Poslat znovu” (resend); disabled stav nastává jen dočasně po
429(throttle hint). - Pokud BE vrátí
email_sent: false, nabídnout inline zkopírování invite URL (jako záloha). - Stav pozvánky (Nepozván / Pozván / Expiroval / Registrován) vizuálně odlišit pill barvou — konzistentní s pillem stavu invite v
InviteListTable. - FE neudržuje vlastní throttle časovač jako autoritativní — 24h limit vynucuje BE; FE disabled po
429je jen UX hint, ne bezpečnostní opatření.
Backend
Validations
| Field | Constraints | Note |
|---|---|---|
id (path param) | positive long | 404 pokud entry neexistuje |
| waitlist entry existence | exists in DB | 404 NOT_FOUND |
| email registrace | users.email NOT EXISTS | 409 CONFLICT_ALREADY_REGISTERED (CLAIMED, terminální) |
| stav invite → akce | NULL → INVITED; EXPIRED/REVOKED → REINVITED; PENDING → RESENT | rozhoduje větvení, ne validační chyba |
| resend throttle (jen PENDING/RESENT) | wasRecentlySent(WAITLIST_INVITE, email) == false | 429 INVITE_RESEND_THROTTLED (okno talkide.email.waitlist-invite-resend-window-hours, default 24) |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| ADMIN autentizovaný, waitlist entry existuje, e-mail neregistrovaný, bez předchozí pozvánky | POST /admin/waitlist/{id}/invite | 201 Created; nový PENDING token v invites (issued_by_user_id=NULL, issued_to_email=entry.email, expires_at=now+14d); waitlist_entry.invited_at a invite_id nastaveny; e-mail odeslán přes EmailDispatchService; email_sent=true v response |
| ADMIN, entry existuje, e-mail neregistrovaný, předchozí invite EXPIRED | POST /admin/waitlist/{id}/invite | 201 Created; action=REINVITED; nový PENDING invite vytvořen; invite_id přepsán; invited_at aktualizován na now; e-mail odeslán |
| ADMIN, entry existuje, e-mail neregistrovaný, předchozí invite REVOKED | POST /admin/waitlist/{id}/invite | 201 Created; action=REINVITED; nový PENDING token; e-mail odeslán |
ADMIN, entry má PENDING invite, žádný WAITLIST_INVITE e-mail na danou adresu v posledních 24h | POST /admin/waitlist/{id}/invite (resend) | 200 OK; action=RESENT; žádný nový invites záznam (token i expires_at shodné s původním); invited_at beze změny; e-mail odeslán se STEJNÝM /join?token=...; nový email_log záznam typu WAITLIST_INVITE |
ADMIN, entry má PENDING invite, WAITLIST_INVITE e-mail na danou adresu byl SENT před < 24h | POST /admin/waitlist/{id}/invite (2. resend) | 429 Too Many Requests, code=INVITE_RESEND_THROTTLED; žádný e-mail neodeslán; žádný nový invites ani email_log SENT záznam |
ADMIN, entry má PENDING invite, předchozí resend WAITLIST_INVITE selhal (email_log status=FAILED, žádný SENT v okně) | POST /admin/waitlist/{id}/invite (další resend) | 200 OK; action=RESENT; e-mail znovu odeslán (FAILED nespotřebovává throttle okno — počítá se jen SENT) |
| ADMIN, entry má PENDING invite, e-mail neodešle (Mailgun timeout) | POST /admin/waitlist/{id}/invite (resend) | 200 OK; action=RESENT; email_sent=false; email_log FAILED; throttle okno nespotřebováno (další resend hned možný) |
| ADMIN, entry — e-mail patří již registrovanému uživateli (CLAIMED) | POST /admin/waitlist/{id}/invite | 409 Conflict, code=CONFLICT_ALREADY_REGISTERED; žádný token nevytvořen ani neodeslán; resend NEpovolen (terminální stav) |
ADMIN, entry má EXPIRED invite, ale e-mail mezitím registrován jiným kanálem (users.email match) | POST /admin/waitlist/{id}/invite | 409 Conflict, code=CONFLICT_ALREADY_REGISTERED; re-invite NEproběhne (autoritativní lookup users.email má přednost před invites.status) |
| ADMIN, entry neexistuje (špatné id) | POST /admin/waitlist/{id}/invite | 404 Not Found, code=NOT_FOUND |
| Uživatel bez role ADMIN | POST /admin/waitlist/{id}/invite | 403 Forbidden, code=FORBIDDEN |
| Neautentizovaný request | POST /admin/waitlist/{id}/invite | 401 Unauthorized |
| ADMIN, validní entry, EmailDispatchService selže (Mailgun timeout) | POST /admin/waitlist/{id}/invite | 201 Created; invite token vytvořen, waitlist_entry aktualizován; email_sent=false v response; záznam v email_log se status=FAILED |
References
- UC-08003 Invite System — invite token model,
GenerateAdminInvitesUseCase, politika kvót - UC-11003 Admin Waitlist List — admin tabulka waitlistu, do níž přibývá tlačítko “Pozvat”
- ADR-025 Transactional Email Mailgun — e-mailová infrastruktura (
EmailDispatchService,EmailSender,EmailTemplates,email_log) - UC-11001 Join Waitlist — vzor resend throttlu (
EmailDispatchService.wasRecentlySent) znovupoužitý proWAITLIST_INVITE - Liquibase changeset
0029-add-invite-to-waitlist-entry.xml— nový soubor (NE editovat existující changesetty)
Thanks for the feedback.