Internal Documentation internal
TalkIDE internal documentation

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 jako GenerateAdminInvitesUseCase.
  • Na rozdíl od POST /admin/invites (batch, bez e-mailu, pro interní použití) tento endpoint posílá invite e-mail automaticky přes EmailDispatchService + 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_at se 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).
  • Resend throttle: max 1 odeslání e-mailu typu WAITLIST_INVITE na danou adresu za 24 h. Princip je stejný jako u waitlist confirmation e-mailu (UC-11001) — EmailDispatchService.wasRecentlySent(type, recipient) čte poslední SENT záznam z email_log a porovnává created_at proti now − windowHours. Při překročení vrací 429 Too Many Requests s kódem INVITE_RESEND_THROTTLED.
  • Datový model: nové sloupce invited_at + invite_id na waitlist_entry — nový Liquibase changeset 0029-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.email match), endpoint vrátí 409 Conflict s kódem CONFLICT_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 na invites.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ěřuje invites.status př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_idinvites.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
Stavinvite_id / invites.statusPOST .../invite chování
NEPOZVÁNinvite_id = NULLVystaví nový PENDING token, expires_at = now + 14d, odešle e-mail. → PENDING
PENDINGinvite_idstatus = PENDINGResend 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
EXPIREDinvite_idstatus = EXPIRED (nebo expires_at < now)Re-invite — vystaví nový PENDING token, přepíše invite_id, odešle e-mail. → PENDING
REVOKEDinvite_idstatus = REVOKEDRe-invite — vystaví nový PENDING token, přepíše invite_id, odešle e-mail. → PENDING
CLAIMEDe-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 se status = CLAIMED. Pokud platí kterékoli z toho → terminální stav. Lookup přes users.email je autoritativní, protože issued_to_email se 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 (typ WAITLIST_INVITE, příjemce = e-mail entry), NE z invited_at. invited_at označuje datum prvního/posledního vystavení tokenu, kdežto throttle hlídá frekvenci odeslání e-mailu (resend tokenu nemění invited_at). Reuse EmailDispatchService.wasRecentlySent(EmailType.WAITLIST_INVITE, email); throttle window navázat na novou property talkide.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 Created pro INVITED a REINVITED (vznikl nový invites záznam), 200 OK pro RESENT (žádný nový zdroj nevznikl, jen znovuodeslán e-mail). FE rozlišuje akci primárně přes pole action, 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
  }
}

actionINVITED (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)

SubjectYou're invited to TalkIDE
Text bodyHi {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 bodyViz 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

SubjectByl(a) jste pozván(a) na TalkIDE
Text bodyAhoj {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 bodyViz 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

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 entryZobrazení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/REVOKEDpill “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”:

  1. Tlačítko přejde do loading stavu (SpinLoader, 18px, text “Odesílám…”).
  2. FE volá POST /api/v1/admin/waitlist/{id}/invite.
  3. Po 201 Created / 200 OK — rozliš dle data.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.
  4. 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í.
  5. Po 409 CONFLICT_ALREADY_REGISTERED: Řádek se aktualizuje na stav “Registrován”, toast “Tento e-mail je již zaregistrovaný.” (var(--fg-3)).
  6. 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

FieldConstraintsSizePatternNote
{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 429 je jen UX hint, ne bezpečnostní opatření.

Backend

Validations

FieldConstraintsNote
id (path param)positive long404 pokud entry neexistuje
waitlist entry existenceexists in DB404 NOT_FOUND
email registraceusers.email NOT EXISTS409 CONFLICT_ALREADY_REGISTERED (CLAIMED, terminální)
stav invite → akceNULL → INVITED; EXPIRED/REVOKED → REINVITED; PENDING → RESENTrozhoduje větvení, ne validační chyba
resend throttle (jen PENDING/RESENT)wasRecentlySent(WAITLIST_INVITE, email) == false429 INVITE_RESEND_THROTTLED (okno talkide.email.waitlist-invite-resend-window-hours, default 24)

Test Cases

GIVENWHENTHEN
ADMIN autentizovaný, waitlist entry existuje, e-mail neregistrovaný, bez předchozí pozvánkyPOST /admin/waitlist/{id}/invite201 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 EXPIREDPOST /admin/waitlist/{id}/invite201 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 REVOKEDPOST /admin/waitlist/{id}/invite201 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 24hPOST /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 < 24hPOST /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}/invite409 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}/invite409 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}/invite404 Not Found, code=NOT_FOUND
Uživatel bez role ADMINPOST /admin/waitlist/{id}/invite403 Forbidden, code=FORBIDDEN
Neautentizovaný requestPOST /admin/waitlist/{id}/invite401 Unauthorized
ADMIN, validní entry, EmailDispatchService selže (Mailgun timeout)POST /admin/waitlist/{id}/invite201 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ý pro WAITLIST_INVITE
  • Liquibase changeset 0029-add-invite-to-waitlist-entry.xml — nový soubor (NE editovat existující changesetty)

Was this page helpful?

Thanks for the feedback.