Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ ALLOWED_ORIGIN=http://localhost:3000

# 'production' disables the X-Dev-User bypass and the dev session shortcut.
ENVIRONMENT=development

# Sympla public API, for the ticket login of Sympla buyers. Token from Sympla: Minha Conta > Integrações.
SYMPLA_TOKEN=
30 changes: 25 additions & 5 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ sequenceDiagram
end
W->>G: GET /events/{slug}/attendees?first=100 (paginado, token de organizador)
G-->>W: tier do ingresso (ou nada)
alt sem tier (não é participante)
alt sem tier (não é participante no guild)
W-->>B: 302 -> site/?error=notattendee
else tier encontrado
W->>D: SELECT budget FROM ticket_tiers WHERE name = tier
Expand All @@ -159,10 +159,10 @@ Passo a passo:
4. `fetchUserInfo(access_token)` — `{ id (sub), name, picture }` do `/oauth/userinfo` do guild.
5. `managerAccessToken(...)` — obtém (ou renova) o token de organizador, lendo/gravando `manager_oauth` no D1 (ver seção acima).
6. `fetchTicketTier(managerToken, EVENT_SLUG, identity.id)` — pagina `/events/{slug}/attendees?first=100` (recursivo, até 30 páginas) procurando o nó cujo `userId` é o da votante, e retorna `node.ticketOrder.eventTicketOrderItems.nodes[0].eventTicketingTier.name`, ou `null` se não houver pedido de ingresso.
7. Sem tier → redireciona pra home com `?error=notattendee` (nenhuma sessão é assinada). Com tier → `budgetForTier(tier)` consulta a tabela `ticket_tiers` no D1 (qualquer tier sem linha lá recebe `1` por padrão).
7. Sem tier → redireciona pra home com `?error=notattendee` (nenhuma sessão é assinada; a mensagem aponta quem comprou na Sympla pro login com ingresso). Com tier → `budgetForTier(tier)` consulta a tabela `ticket_tiers` no D1 (qualquer tier sem linha lá recebe `1` por padrão).
8. `signSession(id, budget, secret, ttl, { name, photo })` assina um JWT (HS256, via `jose`) com o **budget já embutido**. Seta o cookie `vote_session` (`SameSite=None; Secure`, 24h) e redireciona pra `/vote`.
9. **Cuidado:** como o budget fica embutido no JWT no momento do login, mudar o budget de um tier no banco **não afeta quem já está logado** — a pessoa precisa deslogar e logar de novo pra pegar um JWT novo com o budget atualizado. Isso já aconteceu de verdade em teste, não é hipotético.
10. Caminhos de erro (todos redirecionam pra home com um `?error=`, exibido por um handler global na navbar que lê o query param e limpa ele em seguida): `?error=denied` (recusou no guild.host), `?error=state` (state não bate, possível CSRF), `?error=token` (troca do code falhou), `?error=identity` (userinfo ou token de organizador falhou), `?error=notattendee` (sem ingresso pra este evento).
10. Caminhos de erro (todos redirecionam pra home com um `?error=`, exibido por um handler global na navbar que lê o query param e limpa ele em seguida): `?error=denied` (recusou no guild.host), `?error=state` (state não bate, possível CSRF), `?error=token` (troca do code falhou), `?error=identity` (userinfo ou token de organizador falhou), `?error=notattendee` (sem ingresso no guild pra este evento).

### Autenticação a cada request (depois do login)

Expand All @@ -180,6 +180,19 @@ Passo a passo:
- `POST /api/vote` (`voteSubmit`) — corpo `{ talkId, action: 'add' | 'remove' }`, validado com `zod`. Checa `isVotingOpen()` (`VOTE_CLOSES_AT` em `src/server/configs/vote.ts`, uma data ISO fixa no código — mudar a data exige deploy, de propósito, já que muda raramente). `castVote()` (`src/server/repositories/vote.ts`) confere o número de votos atual contra o budget (422 se já bateu o limite) e faz `INSERT OR IGNORE` em `c4p_votes`. `removeVote()` só faz `DELETE`, sem checagem de budget.
- **UX do frontend:** clicar no botão de votar de uma palestra vira o estado local na hora (otimista) e enfileira `{ talkId, action }` numa fila com debounce (1s de espera, reseta a cada novo clique), então uma rajada de cliques vira uma única leva de requests em vez de um request por clique. Isso existe porque, em dev local, o rate limiter compartilha um único bucket entre todos os requests quando não há header `CF-Connecting-IP` — é uma peculiaridade real de dev local, não acontece em produção (a Cloudflare sempre seta esse header lá). Quando o budget acaba, todo card de palestra ainda não votada fica esmaecido e desabilitado (classe CSS `budget-out`), exceto as já votadas, que continuam clicáveis pra desvotar — desvotar reabilita tudo na hora, sem refetch, é tudo derivado do estado local.

### Ingressos da Sympla

Os ingressos passaram a ser vendidos na Sympla, mas o guild.host continua sendo o canal de comunicação dos eventos. Quem compra na Sympla provavelmente não tem conta no guild, e a gente não consegue criar uma pra pessoa, então a Sympla vira um segundo jeito de logar no `/vote`.

- A tela de login do `/vote` mostra os dois caminhos: "Entrar" com o guild (como antes) e um formulário com número do ingresso + e-mail do ingresso.
- `POST /api/vote/ticket` (`authTicket`, `src/server/routes/auth.ts`) — corpo `{ ticketNumber, email }`, validado com `zod`. `fetchSymplaTicket()` (`src/server/helpers/sympla.ts`) chama `GET /public/v1.6.0/events/s36d6ce/participants/ticketNumber/{n}` com o header `s_token`. A API quer o hash do evento (`s36d6ce`, o `id` de `GET /events`), não o `3593934` da URL pública. O número do ingresso tem o formato `UV8M-ZA-U6D6`, e a Sympla só acha ele exatamente assim; o PDF do ingresso mostra sem traços (`UV8MZAU6D6`), então `normalizeTicketNumber()` passa pra maiúsculas e recoloca os traços. Não confundir com o número do pedido (`3DLMEBNC7UQ`). `isValidSymplaLogin()` exige `order_status` em `SYMPLA_PAID_ORDER_STATUS` (hoje só `APPROVED`, conferido num pedido real em 2026-09-25) e o e-mail igual ao do participante (sem diferenciar maiúsculas). Ingresso inexistente volta 404 da API ao vivo (a spec fala 204); os dois viram 422.
- Respostas: `200` + cookie de sessão, `422` pra ingresso inexistente, pedido não aprovado ou e-mail errado (sempre a mesma resposta, pra rota não revelar quais números existem), `502` Sympla fora do ar, `500` `SYMPLA_TOKEN` faltando.
- A sessão usa `userId = sympla:<ticket_number>` e budget `SYMPLA_BUDGET = 1` (a Sympla vende um tier só). O mesmo ingresso sempre cai nos mesmos votos. Cada login confere o pedido de novo, então um ingresso cancelado ou reembolsado (`order_status: CANCELLED`) não loga mais.
- Ingresso cancelado não conta na apuração. O resumo do `/admin/votes` e o detalhe por palestra pedem à Sympla os ingressos cancelados (`GET /participants?cancelled_filter=only`, 500 por página) e descartam os votos de `sympla:<ticket>` que estão na lista. Isso cobre quem votou e cancelou depois, e quem ainda tinha a sessão aberta (24h) quando cancelou. O voto continua no `c4p_votes`; só não entra na contagem. Se a Sympla não responder, o painel conta tudo e devolve `symplaChecked: false`.
- Cada login grava `ticket_number` + e-mail em `sympla_guild_join`. `guild_user_id` fica `NULL` até existir um backfill ligando esses votos a contas do guild.
- `GET /api/vote` devolve `sympla: true` pra essas sessões, e o `/vote` mostra no fim da cédula um convite pra seguir o evento no guild.host (`link.guild` em `src/website/configs/definitions.ts`).
- Referência da API: `https://developers.sympla.com.br/api-docs` (OpenAPI oficial). O token é gerado na Sympla em **_Minha Conta -> Integrações_**. A API não tem webhooks.

### Banco de dados

```mermaid
Expand All @@ -203,6 +216,12 @@ erDiagram
INTEGER expires_at
VARCHAR updated_at
}
sympla_guild_join {
TEXT ticket_number PK
VARCHAR email
TEXT guild_user_id UK
VARCHAR created_at
}
talks {
INTEGER id PK
INTEGER speaker_id FK
Expand All @@ -217,10 +236,11 @@ Ver `resources/schema.sql` pras colunas exatas. Tabelas relevantes pra votação
- **`ticket_tiers`** — `name` (`VARCHAR(200) PRIMARY KEY`, precisa bater exatamente com o nome do tier no guild.host) e `budget`. É uma tabela de **overrides**: `budgetForTier()` retorna `1` por padrão pra qualquer tier sem linha aqui, então só vale adicionar linha pros tiers que têm budget diferente de `1`.
- **`c4p_votes`** — `user_id` é o `sub` (UUID) do guild.host, `talk_id` referencia `talks`, e `UNIQUE(user_id, talk_id)` impede voto duplicado.
- **`manager_oauth`** — tabela de uma linha só (`id INTEGER PRIMARY KEY CHECK(id = 1)`) que guarda o refresh token rotativo do organizador, o access token em cache e a expiração.
- **`sympla_guild_join`** — ingressos da Sympla que já logaram pra votar, com o e-mail. `guild_user_id` (`UNIQUE`, aceita vários `NULL`) fica vazio até o backfill que vai ligar esses votos a contas do guild. Em `c4p_votes`, esses votos aparecem com `user_id = 'sympla:<ticket_number>'`.

### Operações administrativas

Rode com `npx wrangler d1 execute jsconf-br --file=<arquivo.sql>` (adicione `--remote` pra produção).
Rode com `npx wrangler d1 execute jsconf-br --file=<arquivo.sql>` no banco local. Pra produção, use o UUID no lugar do nome: `npx wrangler d1 execute d27cd50c-f3ed-44fc-9297-15eedc8c73a0 --remote --file=<arquivo.sql>` (o `wrangler.jsonc` só tem o placeholder `local`; ver "Operational notes" no `TODO.md`).

- **Adicionar uma palestra votável:** insira o speaker, depois a talk com `status = 2`.

Expand All @@ -247,7 +267,7 @@ Rode com `npx wrangler d1 execute jsconf-br --file=<arquivo.sql>` (adicione `--r

`fetchTicketTier` retorna o tier do **primeiro** nó de participante que bater na lista paginada do guild, não uma agregação/melhor-tier, caso a mesma pessoa apareça mais de uma vez na lista de participantes do evento. Isso ainda não foi testado na prática (só contas com um único ingresso foram usadas nos testes até agora) — é uma limitação conhecida, não um bug observado.

Secrets do Worker: `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET`. O `ALLOWED_ORIGIN` precisa ser a origem do site (não pode ser `*`, senão o cookie de sessão não vai).
Secrets do Worker: `GUILD_OAUTH_CLIENT_ID`, `GUILD_OAUTH_CLIENT_SECRET`, `GUILD_OAUTH_REDIRECT_URI`, `GUILD_ORG_REFRESH_TOKEN`, `SESSION_SECRET`, `SYMPLA_TOKEN`. O `ALLOWED_ORIGIN` precisa ser a origem do site (não pode ser `*`, senão o cookie de sessão não vai).

### Variáveis de ambiente (dois arquivos)

Expand Down
62 changes: 56 additions & 6 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,47 @@
> guild.host login. What's left is deploy + a couple of known caveats. `HANDOVER.md` is deleted —
> this file plus `DEVELOPMENT.md` are now the source of truth.

## Sympla ticket login: test before merging

Branch `feat/sympla-ticket-link`, not merged yet.

- [ ] Push the branch: `git push -u origin feat/sympla-ticket-link`.
- [ ] Add `SYMPLA_TOKEN` to `.dev.vars`.
- [x] `npm run db:init` (creates `sympla_guild_join` locally), then `npm start`.
- [x] Log in with a real Sympla ticket number + the email on that ticket (2026-09-25, via the
local worker; works with `UV8M-ZA-U6D6`, `UV8MZAU6D6` and lowercase).
- [x] Real approved order returns `"order_status": "APPROVED"` (not `"A"`), now in
`SYMPLA_PAID_ORDER_STATUS`.
- [x] The API wants the event hash `s36d6ce`, not the numeric `3593934` from the public URL
("Event not found"). Now in `SYMPLA_EVENT_ID`.
- [x] Failure cases: wrong email and unknown ticket (including the order number `3DLMEBNC7UQ`,
which people confuse with the ticket number) both get 422 "invalid ticket". Unknown tickets
come back as 404 from the live API, not the 204 the spec lists.
- [ ] Log in through the `/vote` page in a browser (the API path is verified, the UI is not).
- [ ] Cast a vote with the Sympla session, then check the guild.host invite at the end of the ballot.
- [ ] Check that a guild.host login with no Guild ticket shows the error pointing to the ticket login.
- [x] `VOTE_CLOSES_AT` moved to the end of 2026-11-01 (Brasília time).
- [x] Prod D1 has `sympla_guild_join` (schema applied and checked 2026-09-25).
- [ ] Confirm the prod `SYMPLA_TOKEN` secret is set (`wrangler secret list` shows names), then merge.

## Operational notes

Two things that cost real time in August 2026, both worth reading before touching prod.

**`npm run db:init:remote` only works in CI.** `wrangler.jsonc` ships `database_id: "local"` as a
placeholder and `tools/prepare-worker.mts` swaps in the real UUID from the `WORKER_D1` secret at
build time. Without `.env` the literal string `local` reaches the API and it fails with
`Invalid property: databaseId => Invalid uuid`. From a laptop, target the database by name:
`Invalid property: databaseId => Invalid uuid`. From a laptop, target the database by UUID:

```sh
npx wrangler d1 execute jsconf --remote --file='resources/schema.sql'
npx wrangler d1 execute d27cd50c-f3ed-44fc-9297-15eedc8c73a0 --remote --file='resources/schema.sql'
```

Note the name is **`jsconf`**, not `jsconf-br`. The binding in `wrangler.jsonc` and the actual D1
database name are different, which is what makes the documented command misleading.
The D1 database is named **`jsconf`** (UUID above, from `npx wrangler d1 list`), while
`wrangler.jsonc` calls it `jsconf-br`. Passing the name `jsconf` does not work: it is not in the
config, so wrangler sends it to the API as a database id and gets a misleading
`Authentication error [code: 10000]`. Passing `WORKER_D1` from a local `.env` does not work either
when that file still holds the `local` placeholder.

**The manager refresh token cannot be shared between environments.** guild rotates it on every use
and revokes the previous one, so if local and prod hold the same token, whichever refreshes first
Expand All @@ -34,7 +60,8 @@ Completed for the 2026 event; kept as the runbook for next time.

1. Confirm `EVENT_SLUG` in `src/server/configs/oauth.ts` matches the real event (`vdc8dh` today).
2. Apply `resources/schema.sql` to prod D1 (idempotent). See the operational note above: use
`npx wrangler d1 execute jsconf --remote --file='resources/schema.sql'` locally, since
`npx wrangler d1 execute d27cd50c-f3ed-44fc-9297-15eedc8c73a0 --remote --file='resources/schema.sql'`
locally, since
`npm run db:init:remote` needs `WORKER_D1` and only works in CI.
3. Seed prod `ticket_tiers` (tier name must match guild.host exactly, case-sensitive):
```sql
Expand All @@ -43,7 +70,9 @@ Completed for the 2026 event; kept as the runbook for next time.
Any tier NOT listed here defaults to budget 1 (see `budgetForTier` in
`src/server/repositories/vote.ts`) — only add rows for overrides.
4. `npm run secret` (`wrangler secret put`) for: `GUILD_OAUTH_CLIENT_ID`,
`GUILD_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, `GUILD_ORG_REFRESH_TOKEN`.
`GUILD_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, `GUILD_ORG_REFRESH_TOKEN`, `SYMPLA_TOKEN`.
Without `SYMPLA_TOKEN`, Sympla buyers can't log in (500 on `/api/vote/ticket`). The Sympla
event hash (`s36d6ce`) is a constant in `src/server/configs/sympla.ts`.
`ALLOWED_ORIGIN` = `https://jsconf.com.br` (never `*` — credentialed cookies need an explicit
origin).
5. Confirm the guild.host OAuth app has `https://api.jsconf.com.br/api/vote/callback` registered
Expand Down Expand Up @@ -97,6 +126,27 @@ attendees still get in as non-admins.

## Known caveats (not blocking, but real)

- **Old Sympla group orders carry the buyer's email on every ticket.** Since 2026-09-25 the Sympla
form asks name, surname and email for every ticket, so each guest logs in with their own email.
Orders placed before that change may still have the buyer's details on all tickets, so those
guests need the buyer's email to log in. If one reports "invalid ticket" with their own email,
that's the likely cause.
- **Sympla voters and guild.host accounts are not linked yet.** Sympla buyers vote as
`sympla:<ticket_number>`; `sympla_guild_join` keeps each ticket's email with `guild_user_id`
NULL. Backfill later by matching those emails to guild accounts. A person who logs in both ways
with the same ticket bought once on Sympla can't double vote (the guild login has no ticket), but
someone with a guild ticket AND a Sympla ticket votes with both, which is correct: two tickets.

- **Sympla statuses seen live (2026-09-25):** `APPROVED` for a paid order, `CANCELLED` after
cancelling it. Only `APPROVED` logs in. A refunded order has not been observed; anything other
than `APPROVED` is rejected, so the failure mode is "can't log in", never "votes without paying".
- **Cancelled Sympla votes stay in `c4p_votes` but are left out of the tally.** `/admin/votes`
asks Sympla for cancelled tickets on every load (`cancelled_filter=only`). If Sympla is down the
dashboard counts everything and returns `symplaChecked: false`; the UI doesn't show that flag
yet, so check the response before trusting a tally taken during a Sympla outage.
- **Voting closes at the end of 2026-11-01, Brasília time** (`VOTE_CLOSES_AT =
2026-11-02T03:00:00Z`). The date is hardcoded, so changing it needs a deploy.

- **Budget is baked into the session JWT at login time.** Changing a tier's `budget` in
`ticket_tiers` does NOT retroactively update anyone already logged in — they need to log out and
back in. Hit this directly during testing.
Expand Down
50 changes: 46 additions & 4 deletions i18n/en-US/code.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@
"message": "Vote for talks"
},
"vote.subheading": {
"message": "Choose the talks you want to see on stage."
"message": "At JSConf 2026, {you} choose the talks we will see on stage!"
},
"vote.subheadingYou": {
"message": "you"
},
"vote.hideAuthorsTitle": {
"message": "Why do we hide the authors' names?"
Expand All @@ -423,7 +426,13 @@
"message": "To reduce bias, the names of who proposed each talk are hidden. Vote for the talk's content, not for who wrote it."
},
"vote.loginHeading": {
"message": "Sign in to vote"
"message": "How did you buy your ticket?"
},
"vote.loginSubheading": {
"message": "Because we changed ticketing providers, you may have bought your ticket in one of two ways: through our partner Guild.host or through Sympla."
},
"vote.guildHeading": {
"message": "Through Guild.host"
},
"vote.closedHeading": {
"message": "Voting is closed"
Expand All @@ -441,7 +450,7 @@
"message": "Voted"
},
"vote.loginPrompt": {
"message": "Sign in with your guild.host account to vote for talks."
"message": "Log in with your guild.host account to vote!"
},
"vote.loadError": {
"message": "Could not load voting. Try again later."
Expand Down Expand Up @@ -473,8 +482,41 @@
"login.error.identity": {
"message": "Could not identify your guild.host account."
},
"vote.ticket.heading": {
"message": "Through Sympla?"
},
"vote.ticket.prompt": {
"message": "Log in with your ticket number and the email on the ticket!"
},
"vote.ticket.number": {
"message": "Ticket number"
},
"vote.ticket.numberPlaceholder": {
"message": "Ticket number, e.g. UV8MZAU6D6 (with or without dashes)"
},
"vote.ticket.email": {
"message": "Ticket email"
},
"vote.ticket.submit": {
"message": "Log in with ticket"
},
"vote.ticket.invalid": {
"message": "Ticket not found, order not approved, or the email doesn't match the ticket."
},
"vote.ticket.error": {
"message": "Couldn't check the ticket right now. Try again."
},
"vote.guildInvite.title": {
"message": "Follow JSConf on guild.host"
},
"vote.guildInvite.body": {
"message": "NodeBR is on Guild.host! That's where you'll find more about upcoming events, meetups and a lot more. Sign up, it's free!"
},
"vote.guildInvite.cta": {
"message": "Go to guild.host"
},
"login.error.notattendee": {
"message": "You do not have a ticket for the event."
"message": "We couldn't find a ticket on your guild.host account. If you bought on Sympla, log in with your ticket number on the voting page."
},
"login.error.generic": {
"message": "Login error. Try again."
Expand Down
Loading
Loading