diff --git a/.env.example b/.env.example index 565a90e..a834cce 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,12 @@ CF_ZONE_ID='' CF_TOKEN='' CLOUDFLARE_ACCOUNT_ID='' -WORKER_DOMAIN='' +WORKER_DOMAIN='http://localhost:8787' WORKER_D1='' + +# Required for local dev — copy to .dev.vars +ADMIN_KEY='' +JWT_SECRET='' ALLOWED_ORIGIN='' STRIPE_SECRET_KEY='' STRIPE_WEBHOOK_SECRET='' diff --git a/.gitignore b/.gitignore index 6dddee8..cb81394 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .env .dev.vars .npmrc +src/server/routes/transactions.json diff --git a/i18n/en-US/code.json b/i18n/en-US/code.json index 05d46ee..00e2137 100644 --- a/i18n/en-US/code.json +++ b/i18n/en-US/code.json @@ -346,5 +346,101 @@ }, "coc.pageDescription": { "message": "JSConf Brasil Code of Conduct" + }, + "voting.modal.step1.title": { + "message": "Request access code" + }, + "voting.modal.step1.email.label": { + "message": "Email" + }, + "voting.modal.step1.email.placeholder": { + "message": "your@email.com" + }, + "voting.modal.step1.submit": { + "message": "Send code" + }, + "voting.modal.step1.success": { + "message": "Code sent! Check your email." + }, + "voting.modal.step1.error": { + "message": "Failed to send code. Try again." + }, + "voting.modal.step2.title": { + "message": "Enter your code" + }, + "voting.modal.step2.code.label": { + "message": "4-digit code" + }, + "voting.modal.step2.code.placeholder": { + "message": "0000" + }, + "voting.modal.step2.submit": { + "message": "Sign in" + }, + "voting.modal.step2.error.invalid": { + "message": "Invalid code." + }, + "voting.modal.step2.error.notFound": { + "message": "Code not found." + }, + "voting.modal.back": { + "message": "Back" + }, + "voting.cta.label": { + "message": "Vote on talks" + }, + "voting.cta.title": { + "message": "Voting is open!" + }, + "voting.cta.description": { + "message": "Voting for the talks is now open. Participate!" + }, + "voting.page.title": { + "message": "Voting" + }, + "voting.page.noCode": { + "message": "Access code not provided." + }, + "voting.page.comingSoon": { + "message": "Voting interface under development." + }, + "voting.page.loading": { + "message": "Loading votes..." + }, + "voting.page.retry": { + "message": "Try again" + }, + "voting.page.votesRemaining": { + "message": "{count} votes remaining" + }, + "voting.page.noVotesLeft": { + "message": "You have used all your votes." + }, + "voting.page.noTalks": { + "message": "No talks available for voting yet." + }, + "voting.page.talk.vote": { + "message": "Vote" + }, + "voting.page.talk.retract": { + "message": "Retract" + }, + "voting.page.voteSuccess": { + "message": "Vote registered!" + }, + "voting.page.retractSuccess": { + "message": "Vote removed." + }, + "voting.page.error.invalidCode": { + "message": "Invalid or expired code." + }, + "voting.page.error.generic": { + "message": "Failed to load votes." + }, + "voting.page.error.voteFailed": { + "message": "Failed to register vote." + }, + "voting.page.error.retractFailed": { + "message": "Failed to remove vote." } } diff --git a/i18n/es-419/code.json b/i18n/es-419/code.json index 762b807..23dba85 100644 --- a/i18n/es-419/code.json +++ b/i18n/es-419/code.json @@ -346,5 +346,101 @@ }, "coc.pageDescription": { "message": "Código de Conducta de JSConf Brasil" + }, + "voting.modal.step1.title": { + "message": "Solicitar código de acceso" + }, + "voting.modal.step1.email.label": { + "message": "Correo electrónico" + }, + "voting.modal.step1.email.placeholder": { + "message": "tu@email.com" + }, + "voting.modal.step1.submit": { + "message": "Enviar código" + }, + "voting.modal.step1.success": { + "message": "¡Código enviado! Revisa tu correo." + }, + "voting.modal.step1.error": { + "message": "Error al enviar código. Intenta de nuevo." + }, + "voting.modal.step2.title": { + "message": "Ingresa tu código" + }, + "voting.modal.step2.code.label": { + "message": "Código de 4 dígitos" + }, + "voting.modal.step2.code.placeholder": { + "message": "0000" + }, + "voting.modal.step2.submit": { + "message": "Entrar" + }, + "voting.modal.step2.error.invalid": { + "message": "Código inválido." + }, + "voting.modal.step2.error.notFound": { + "message": "Código no encontrado." + }, + "voting.modal.back": { + "message": "Volver" + }, + "voting.cta.label": { + "message": "Votar por las charlas" + }, + "voting.cta.title": { + "message": "¡Votación abierta!" + }, + "voting.cta.description": { + "message": "Las votaciones para elegir las charlas están abiertas. ¡Participa!" + }, + "voting.page.title": { + "message": "Votación" + }, + "voting.page.noCode": { + "message": "Código de acceso no proporcionado." + }, + "voting.page.comingSoon": { + "message": "Interfaz de votación en desarrollo." + }, + "voting.page.loading": { + "message": "Cargando votaciones..." + }, + "voting.page.retry": { + "message": "Intentar de nuevo" + }, + "voting.page.votesRemaining": { + "message": "{count} votos restantes" + }, + "voting.page.noVotesLeft": { + "message": "Ya usaste todos tus votos." + }, + "voting.page.noTalks": { + "message": "No hay charlas disponibles para votar todavía." + }, + "voting.page.talk.vote": { + "message": "Votar" + }, + "voting.page.talk.retract": { + "message": "Retirar" + }, + "voting.page.voteSuccess": { + "message": "¡Voto registrado!" + }, + "voting.page.retractSuccess": { + "message": "Voto eliminado." + }, + "voting.page.error.invalidCode": { + "message": "Código inválido o expirado." + }, + "voting.page.error.generic": { + "message": "Error al cargar votaciones." + }, + "voting.page.error.voteFailed": { + "message": "Error al registrar voto." + }, + "voting.page.error.retractFailed": { + "message": "Error al eliminar voto." } } diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index 4231567..7e1721f 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -346,5 +346,101 @@ }, "tickets.subtitle": { "message": "Garanta agora seu lugar na maior conferência de JavaScript do Brasil." + }, + "voting.modal.step1.title": { + "message": "Solicitar código de acesso" + }, + "voting.modal.step1.email.label": { + "message": "Email" + }, + "voting.modal.step1.email.placeholder": { + "message": "seu@email.com" + }, + "voting.modal.step1.submit": { + "message": "Enviar código" + }, + "voting.modal.step1.success": { + "message": "Código enviado! Verifique seu email." + }, + "voting.modal.step1.error": { + "message": "Erro ao enviar código. Tente novamente." + }, + "voting.modal.step2.title": { + "message": "Digite seu código" + }, + "voting.modal.step2.code.label": { + "message": "Código de 4 dígitos" + }, + "voting.modal.step2.code.placeholder": { + "message": "0000" + }, + "voting.modal.step2.submit": { + "message": "Entrar" + }, + "voting.modal.step2.error.invalid": { + "message": "Código inválido." + }, + "voting.modal.step2.error.notFound": { + "message": "Código não encontrado." + }, + "voting.modal.back": { + "message": "Voltar" + }, + "voting.cta.label": { + "message": "Votar nas palestras" + }, + "voting.cta.title": { + "message": "Votação aberta!" + }, + "voting.cta.description": { + "message": "As votações para escolha das palestras estão abertas. Participe!" + }, + "voting.page.title": { + "message": "Votação" + }, + "voting.page.noCode": { + "message": "Código de acesso não fornecido." + }, + "voting.page.comingSoon": { + "message": "Interface de votação em desenvolvimento." + }, + "voting.page.loading": { + "message": "Carregando votações..." + }, + "voting.page.retry": { + "message": "Tentar novamente" + }, + "voting.page.votesRemaining": { + "message": "{count} votos restantes" + }, + "voting.page.noVotesLeft": { + "message": "Você usou todos os seus votos." + }, + "voting.page.noTalks": { + "message": "Nenhuma palestra disponível para votação ainda." + }, + "voting.page.talk.vote": { + "message": "Votar" + }, + "voting.page.talk.retract": { + "message": "Retirar" + }, + "voting.page.voteSuccess": { + "message": "Voto registrado!" + }, + "voting.page.retractSuccess": { + "message": "Voto removido." + }, + "voting.page.error.invalidCode": { + "message": "Código inválido ou expirado." + }, + "voting.page.error.generic": { + "message": "Erro ao carregar votações." + }, + "voting.page.error.voteFailed": { + "message": "Erro ao registrar voto." + }, + "voting.page.error.retractFailed": { + "message": "Erro ao remover voto." } } diff --git a/package-lock.json b/package-lock.json index 5dbf850..b7d6725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { + "jose": "^6.2.2", "stripe": "^22.0.0" }, "devDependencies": { @@ -26,9 +27,11 @@ "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", "@tailwindcss/postcss": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "better-sqlite3": "^12.9.0", "concurrently": "^9.2.1", "docusaurus-plugin-sass": "^0.2.6", "jsonc.min": "^1.1.2", @@ -5096,9 +5099,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5116,9 +5116,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5136,9 +5133,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5156,9 +5150,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5176,9 +5167,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5196,9 +5184,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5216,9 +5201,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5236,9 +5218,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5256,9 +5235,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5282,9 +5258,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5308,9 +5281,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5334,9 +5304,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5360,9 +5327,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5386,9 +5350,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5412,9 +5373,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5438,9 +5396,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -6301,9 +6256,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6325,9 +6277,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6349,9 +6298,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6373,9 +6319,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6397,9 +6340,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -6421,9 +6361,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7028,9 +6965,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7045,9 +6979,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7062,9 +6993,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7079,9 +7007,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7096,9 +7021,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7113,9 +7035,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7130,9 +7049,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7147,9 +7063,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7164,9 +7077,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7181,9 +7091,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7198,9 +7105,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7215,9 +7119,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7232,9 +7133,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7815,9 +7713,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7835,9 +7730,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7855,9 +7747,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7875,9 +7764,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7965,6 +7851,16 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -9106,6 +9002,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", @@ -9126,6 +9043,21 @@ "dev": true, "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -9149,6 +9081,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -9307,6 +9261,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9644,6 +9623,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -11279,6 +11265,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -11744,6 +11740,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -12074,6 +12080,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -12241,6 +12254,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -12373,6 +12393,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/github-slugger": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", @@ -13199,6 +13226,27 @@ "postcss": "^8.1.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -13801,6 +13849,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -14107,9 +14164,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14131,9 +14185,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14155,9 +14206,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14179,9 +14227,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17024,6 +17069,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -17074,6 +17126,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -17102,6 +17161,19 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -17347,6 +17419,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -19354,6 +19436,34 @@ "postcss": "^8.4.31" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -19490,6 +19600,17 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -21255,6 +21376,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -21779,6 +21947,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/terser": { "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", @@ -22047,6 +22245,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -23777,6 +23988,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", diff --git a/package.json b/package.json index 11dee06..7c9340a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "lint": "prettier --check .", "lint:fix": "prettier --write .", "update": "pu && npm i && npm update && (npm audit fix || true) && npm run lint:fix", + "seed": "tsx scripts/seed.ts", + "stripe:fetch": "tsx --env-file=.dev.vars scripts/fetch-stripe-data.ts", "purge": "tsx --env-file-if-exists=.env tools/purge-cache.mts", "test": "poku -r=compact", "deploy": "wrangler deploy --config server/wrangler.jsonc server/index.js", @@ -44,9 +46,11 @@ "@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-typescript": "^12.3.0", "@tailwindcss/postcss": "^4.2.2", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "better-sqlite3": "^12.9.0", "concurrently": "^9.2.1", "docusaurus-plugin-sass": "^0.2.6", "jsonc.min": "^1.1.2", @@ -89,6 +93,7 @@ }, "private": true, "dependencies": { + "jose": "^6.2.2", "stripe": "^22.0.0" } } diff --git a/resources/schema.sql b/resources/schema.sql index c226e04..363de6f 100644 --- a/resources/schema.sql +++ b/resources/schema.sql @@ -57,3 +57,33 @@ CREATE TABLE IF NOT EXISTS speaker_diversity ( ); CREATE INDEX IF NOT EXISTS idx_speaker_diversity_speaker_id ON speaker_diversity(speaker_id); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL COLLATE NOCASE, + ticket_type INTEGER NOT NULL CHECK (ticket_type > 0), + votes_allowed INTEGER NOT NULL CHECK (votes_allowed >= 0), + quantity INTEGER NOT NULL DEFAULT 1, + code TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + refresh_token_hash TEXT, + refresh_token_expires_at INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_code ON users(code); + +CREATE TABLE IF NOT EXISTS votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + talk_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (talk_id) REFERENCES talks(id), + UNIQUE (user_id, talk_id) +); + +CREATE INDEX IF NOT EXISTS idx_votes_user_id ON votes(user_id); +CREATE INDEX IF NOT EXISTS idx_votes_talk_id ON votes(talk_id); + + diff --git a/scripts/fetch-stripe-data.ts b/scripts/fetch-stripe-data.ts new file mode 100644 index 0000000..94902e0 --- /dev/null +++ b/scripts/fetch-stripe-data.ts @@ -0,0 +1,66 @@ +import { writeFileSync } from 'node:fs'; +import Stripe from 'stripe'; +import { createStripeClient } from '../src/server/configs/stripe.js'; + +const STRIPE_SECRET_KEY = process.env['STRIPE_SECRET_KEY']; + +if (!STRIPE_SECRET_KEY) { + console.error('Error: STRIPE_SECRET_KEY environment variable is not set.'); + process.exit(1); +} + +const stripe = createStripeClient(STRIPE_SECRET_KEY); + +async function main() { + const customers: Stripe.Customer[] = []; + const paymentIntents: Stripe.PaymentIntent[] = []; + const charges: Stripe.Charge[] = []; + + for await (const customer of stripe.customers.list({ limit: 100 })) { + customers.push(customer); + } + + for await (const paymentIntent of stripe.paymentIntents.list({ + limit: 100, + })) { + paymentIntents.push(paymentIntent); + } + + for await (const charge of stripe.charges.list({ limit: 100 })) { + charges.push(charge); + } + + const output = { + fetchedAt: new Date().toISOString(), + customers, + paymentIntents, + charges, + }; + + writeFileSync( + 'src/server/routes/transactions.json', + JSON.stringify(output, null, 2) + ); + + console.log( + `Fetched ${customers.length} customers, ${paymentIntents.length} payment intents, ${charges.length} charges. Written to src/server/routes/transactions.json` + ); + + const sessionsWithQuantity = paymentIntents.filter( + (pi) => pi.metadata?.['quantity'] + ); + const totalTickets = sessionsWithQuantity.reduce( + (sum, pi) => sum + parseInt(pi.metadata['quantity'] || '1', 10), + 0 + ); + + console.log( + `\nQuantity summary: ${sessionsWithQuantity.length} of ${paymentIntents.length} payment intents have quantity metadata.` + ); + console.log(`Total tickets (from metadata.quantity): ${totalTickets}`); +} + +main().catch((err) => { + console.error('Failed to fetch Stripe data:', err); + process.exit(1); +}); diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..4963a66 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,124 @@ +import { execSync } from 'node:child_process'; + +const SERVER = process.argv.includes('--server') + ? process.argv[process.argv.indexOf('--server') + 1] + : 'http://localhost:8787'; +const ADMIN_KEY = process.env['ADMIN_KEY'] || 'minha-chave-admin'; + +type UsersResult = { + results: Array<{ email: string; code: string }>; +}; + +async function main() { + // Step 1: Create users + console.log('Creating users...'); + const userRes = await fetch(`${SERVER}/api/users/batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ADMIN_KEY: ADMIN_KEY, + }, + body: JSON.stringify({ + users: [ + { email: 'alice@example.com', ticket_type: 1, votes_allowed: 5 }, + { email: 'bob@example.com', ticket_type: 1, votes_allowed: 5 }, + ], + }), + }); + + if (!userRes.ok) { + const err = await userRes.text(); + throw new Error(`Failed to create users: ${userRes.status} ${err}`); + } + + const userData = (await userRes.json()) as UsersResult; + console.log('Users created:', JSON.stringify(userData.results, null, 2)); + + // Step 2: Create C4P talks (3 different speakers/talks) + const talks = [ + { + name: 'Ana Silva', + email: 'ana@example.com', + phone: '11999999999', + city: 'São Paulo', + state: 'SP', + travelPreference: 0, + experienceLevel: 2, + bio: 'Software engineer passionate about web performance.', + duration: 0, + talkTitle: 'Web Performance Optimizations That Actually Matter', + talkDescription: + 'A practical guide to web performance optimizations that make a real difference in production.', + audienceLevel: 1, + talkReason: + 'Sharing real-world experience from optimizing large-scale applications.', + }, + { + name: 'Carlos Oliveira', + email: 'carlos@example.com', + phone: '21988888888', + city: 'Rio de Janeiro', + state: 'RJ', + travelPreference: 1, + experienceLevel: 2, + bio: 'Full-stack developer and open source contributor.', + duration: 1, + talkTitle: 'Building Resilient APIs with TypeScript', + talkDescription: + 'How to build APIs that handle failure gracefully in production.', + audienceLevel: 2, + talkReason: 'Lessons learned from migrating a monolith to microservices.', + }, + { + name: 'Julia Santos', + email: 'julia@example.com', + phone: '31977777777', + city: 'Belo Horizonte', + state: 'MG', + travelPreference: 0, + experienceLevel: 1, + bio: 'UX engineer focused on accessibility and inclusive design.', + duration: 0, + talkTitle: 'Accessibility-First Development: A Practical Approach', + talkDescription: + 'How to build accessible web applications from the ground up.', + audienceLevel: 0, + talkReason: + 'Accessibility is often treated as an afterthought — it should be foundational.', + }, + ]; + + for (const talk of talks) { + console.log(`Creating talk: "${talk.talkTitle}"...`); + const talkRes = await fetch(`${SERVER}/api/c4p`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(talk), + }); + + if (!talkRes.ok) { + const err = await talkRes.text(); + console.error(`Talk creation failed (${talkRes.status}): ${err}`); + } else { + console.log(' Done.'); + } + } + + // Step 3: Update talk statuses to appear in voting + console.log('Updating talk statuses to 2 (voting)...'); + execSync( + `wrangler d1 execute jsconf-br --command="UPDATE talks SET status = 2"`, + { cwd: process.cwd(), stdio: 'inherit' } + ); + + console.log('\n--- Seed complete ---'); + console.log('Auth codes (use to log in):'); + for (const u of userData.results) { + console.log(` ${u.email} → code: ${u.code}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/server/code-sender.ts b/src/server/code-sender.ts new file mode 100644 index 0000000..7649b41 --- /dev/null +++ b/src/server/code-sender.ts @@ -0,0 +1,16 @@ +/** + * Interface for code delivery implementations. + * Swap this out for email, WhatsApp, etc. without changing business logic. + */ +export interface CodeSender { + sendCode(email: string, code: string): Promise; +} + +/** + * Logs the code that would be sent. For development / testing. + */ +export const createLoggingCodeSender = (): CodeSender => ({ + sendCode: async (email: string, code: string) => { + console.log(`[CodeSender] Sending code ${code} to ${email}`); + }, +}); diff --git a/src/server/helpers/request.ts b/src/server/helpers/request.ts index 17af224..59c993f 100644 --- a/src/server/helpers/request.ts +++ b/src/server/helpers/request.ts @@ -1,13 +1,49 @@ +import { z } from 'zod'; +import { response } from './response.js'; + export const isJsonContentType = (request: Request): boolean => (request.headers.get('Content-Type') ?? '').includes('application/json'); -export const isWithinSize = (text: string, max: number): boolean => - text.length <= max; +/** + * Parses and validates a request body against a Zod schema. + * Returns the parsed body on success, or a Response (with error) on failure. + * Usage in route: + * const parsed = await parseRequest(request, schema, cors); + * if (parsed instanceof Response) return parsed; + * // body is typed as T here + */ +export async function parseRequest( + request: Request, + schema: z.Schema, + cors: Record +): Promise { + if (!isJsonContentType(request)) { + return response( + { error: 'Content-Type must be application/json' }, + 415, + cors + ); + } -export const parseBody = (text: string): unknown | undefined => { + let text: string; try { - return JSON.parse(text); + text = await request.text(); } catch { - return; + return response({ error: 'Invalid JSON.' }, 400, cors); } -}; + + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return response({ error: 'Invalid JSON.' }, 400, cors); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + const details = z.treeifyError(parsed.error); + return response({ error: 'Validation failed', details }, 422, cors); + } + + return parsed.data; +} diff --git a/src/server/index.ts b/src/server/index.ts index 8bd8143..80397f1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ import type { Env } from './types.js'; +import { createLoggingCodeSender } from './code-sender.js'; import { checkRateLimit, getRateLimitKey } from './configs/rate-limit.js'; import { hash } from './helpers/hash.js'; import { response } from './helpers/response.js'; @@ -9,8 +10,8 @@ export default { const origin = env.ALLOWED_ORIGIN || '*'; const cors = { 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', Vary: 'Origin', }; @@ -21,23 +22,51 @@ export default { const { pathname } = new URL(request.url); if (`${method} ${pathname}` === 'POST /api/stripe/webhook') - return routes.stripeWebhook({ request, cors, env }); + return routes.stripeWebhook({ request, cors, env, database: env.DB }); - const rateLimit = checkRateLimit(request); - if (!rateLimit.allowed) - return response({ error: 'Rate limit exceeded.' }, 429, { - ...cors, - 'Retry-After': String(rateLimit.retryAfterSeconds), - }); + if (env.ENVIRONMENT === 'production') { + const rateLimit = checkRateLimit(request); + if (!rateLimit.allowed) + return response({ error: 'Rate limit exceeded.' }, 429, { + ...cors, + 'Retry-After': String(rateLimit.retryAfterSeconds), + }); + } - const ip = await hash(getRateLimitKey(request)); + const votingContext = { + request, + cors, + database: env.DB, + env, + sender: createLoggingCodeSender(), + }; // Routes switch (`${method} ${pathname}`) { - case 'POST /api/c4p': - return routes.c4p({ request, cors, database: env.DB, ip }); - case 'POST /api/waitlist': - return routes.waitlist({ request, cors, database: env.DB, ip }); + case 'POST /api/c4p': { + const ip = await hash(getRateLimitKey(request)); + return routes.c4p({ ...votingContext, ip }); + } + case 'POST /api/waitlist': { + const ip = await hash(getRateLimitKey(request)); + return routes.waitlist({ ...votingContext, ip }); + } + case 'POST /api/users/batch': + return routes.usersBatch(votingContext); + case 'POST /api/auth/login': + return routes.login(votingContext); + case 'POST /api/auth/refresh': + return routes.refresh(votingContext); + case 'POST /api/auth/logout': + return routes.logout(votingContext); + case 'GET /api/voting/state': + return routes.votingState(votingContext); + case 'POST /api/voting/vote': + return routes.votingVote(votingContext); + case 'DELETE /api/voting/vote': + return routes.votingRetract(votingContext); + case 'POST /api/auth/request-code': + return routes.requestCode(votingContext); default: return response({ error: 'Not found.' }, 404, cors); } diff --git a/src/server/lib/tokens.ts b/src/server/lib/tokens.ts new file mode 100644 index 0000000..3ba6a1f --- /dev/null +++ b/src/server/lib/tokens.ts @@ -0,0 +1,58 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { jwtVerify, SignJWT } from 'jose'; + +export async function generateTokens( + user: { id: number; email: string }, + secret: string +): Promise<{ access_token: string; refresh_token: string }> { + const encodedSecret = new TextEncoder().encode(secret); + + const access_token = await new SignJWT({ + sub: String(user.id), + email: user.email, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('15m') + .sign(encodedSecret); + + const refresh_token = randomBytes(64).toString('hex'); + + return { access_token, refresh_token }; +} + +export async function validateAccessToken( + token: string, + secret: string +): Promise<{ sub: string; email: string; iat: number; exp: number }> { + const encodedSecret = new TextEncoder().encode(secret); + const { payload } = await jwtVerify(token, encodedSecret); + + return { + sub: payload['sub'] as string, + email: payload['email'] as string, + iat: payload['iat'] as number, + exp: payload['exp'] as number, + }; +} + +export function hashRefreshToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function validateRefreshTokenHash(token: string, hash: string): boolean { + const tokenHash = hashRefreshToken(token); + + try { + return timingSafeEqual( + Buffer.from(tokenHash, 'hex'), + Buffer.from(hash, 'hex') + ); + } catch { + return false; + } +} + +export function rotateRefreshToken(_token: string): string { + return randomBytes(64).toString('hex'); +} diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts new file mode 100644 index 0000000..162454d --- /dev/null +++ b/src/server/middleware/auth.ts @@ -0,0 +1,62 @@ +import type { Database, Env } from '../types.js'; +import { response } from '../helpers/response.js'; +import { validateAccessToken } from '../lib/tokens.js'; +import { user } from '../repositories/user.js'; + +export async function requireAuth( + request: Request, + env: Env, + database: Database, + cors?: Record +): Promise< + | { + user: { + id: number; + email: string; + ticket_type: number; + votes_allowed: number; + quantity: number; + }; + request: Request; + } + | Response +> { + const authHeader = request.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + if (cors) { + return response({ error: 'unauthorized' }, 401, cors); + } + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const token = authHeader.slice(7); + let payload: { sub: string; email: string }; + try { + payload = await validateAccessToken(token, env.JWT_SECRET); + } catch { + if (cors) { + return response({ error: 'unauthorized' }, 401, cors); + } + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const usersRepo = user(database); + const u = await usersRepo.findById(Number(payload.sub)); + if (!u) { + if (cors) { + return response({ error: 'unauthorized' }, 401, cors); + } + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return { user: u, request }; +} diff --git a/src/server/repositories/user.ts b/src/server/repositories/user.ts new file mode 100644 index 0000000..9da35ff --- /dev/null +++ b/src/server/repositories/user.ts @@ -0,0 +1,238 @@ +import type { Database } from '../types.js'; +import { z } from 'zod'; + +const intEnum = (max: number) => z.number().int().min(0).max(max); + +export const userSchema = z.object({ + email: z.email(), + ticket_type: intEnum(10), + votes_allowed: intEnum(100), + quantity: intEnum(10), +}); + +const generateCode = () => String(Math.floor(1000 + Math.random() * 9000)); + +export const user = (database: Database) => { + const schema = userSchema; + + const findByCode = async (code: string) => { + const { results } = await database + .prepare( + 'SELECT id, email, ticket_type, votes_allowed, quantity, code FROM users WHERE code = ?' + ) + .bind(code) + .all<{ + id: number; + email: string; + ticket_type: number; + votes_allowed: number; + quantity: number; + code: string; + }>(); + return results[0] || null; + }; + + const votesRemaining = async (userId: number) => { + const { results } = await database + .prepare('SELECT COUNT(*) as count FROM votes WHERE user_id = ?') + .bind(userId) + .all<{ count: number }>(); + return results[0]?.count ?? 0; + }; + + const insert = async (data: z.infer) => { + let code = generateCode(); + let attempts = 0; + while (attempts < 10) { + const existing = await database + .prepare('SELECT id FROM users WHERE code = ?') + .bind(code) + .all<{ id: number }>(); + if (existing.results.length === 0) break; + code = generateCode(); + attempts++; + } + await database + .prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, quantity, code) VALUES (?, ?, ?, ?, ?)' + ) + .bind( + data.email, + data.ticket_type, + data.votes_allowed, + data.quantity ?? 1, + code + ) + .run(); + return code; + }; + + const insertBatch = async (items: z.infer[]) => { + const results: { email: string; code: string }[] = []; + const usedCodes = new Set(); + for (const item of items) { + let code = generateCode(); + let attempts = 0; + while (attempts < 10) { + if (!usedCodes.has(code)) { + const existing = await database + .prepare('SELECT id FROM users WHERE code = ?') + .bind(code) + .all<{ id: number }>(); + if (existing.results.length === 0) break; + } + code = generateCode(); + attempts++; + } + usedCodes.add(code); + await database + .prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, quantity, code) VALUES (?, ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET ticket_type = excluded.ticket_type, votes_allowed = users.votes_allowed + excluded.votes_allowed, quantity = users.quantity + excluded.quantity, code = excluded.code' + ) + .bind( + item.email, + item.ticket_type, + item.votes_allowed, + item.quantity ?? 1, + code + ) + .run(); + results.push({ email: item.email, code }); + } + return results; + }; + + const revoke = async (email: string) => { + const { results } = await database + .prepare('SELECT id FROM users WHERE email = ?') + .bind(email) + .all<{ id: number }>(); + + const u = results[0]; + if (!u) return false; + + await database + .prepare('DELETE FROM votes WHERE user_id = ?') + .bind(u.id) + .run(); + + await database + .prepare('UPDATE users SET votes_allowed = 0 WHERE id = ?') + .bind(u.id) + .run(); + + return true; + }; + + const getEligibleTalks = async (userId?: number) => { + const { results } = await database + .prepare( + ` + SELECT t.id, t.title, t.description, t.duration, t.audience_level, + s.name as speaker_name, + ${userId != null ? `CAST(EXISTS(SELECT 1 FROM votes v WHERE v.user_id = ? AND v.talk_id = t.id) AS INTEGER) as has_voted` : '0 as has_voted'} + FROM talks t + INNER JOIN speakers s ON t.speaker_id = s.id + WHERE t.status = 2 + ` + ) + .bind(...(userId != null ? [userId] : [])) + .all<{ + id: number; + title: string; + description: string; + duration: number; + audience_level: number; + speaker_name: string; + has_voted: number; + }>(); + return results; + }; + + const findByEmail = async (email: string) => { + const { results } = await database + .prepare( + 'SELECT id, email, ticket_type, votes_allowed, quantity, code FROM users WHERE email = ?' + ) + .bind(email) + .all<{ + id: number; + email: string; + ticket_type: number; + votes_allowed: number; + quantity: number; + code: string; + }>(); + return results[0] || null; + }; + + const findById = async (id: number) => { + const { results } = await database + .prepare( + 'SELECT id, email, ticket_type, votes_allowed, quantity FROM users WHERE id = ?' + ) + .bind(id) + .all<{ + id: number; + email: string; + ticket_type: number; + votes_allowed: number; + quantity: number; + }>(); + return results[0] || null; + }; + + const setRefreshToken = async ( + email: string, + tokenHash: string, + expiresAt: number + ) => { + await database + .prepare( + 'UPDATE users SET refresh_token_hash = ?, refresh_token_expires_at = ? WHERE email = ?' + ) + .bind(tokenHash, expiresAt, email) + .run(); + }; + + const findByRefreshTokenHash = async (hash: string) => { + const now = Math.floor(Date.now() / 1000); + const { results } = await database + .prepare( + 'SELECT id, email, ticket_type, votes_allowed, quantity FROM users WHERE refresh_token_hash = ? AND refresh_token_expires_at > ?' + ) + .bind(hash, now) + .all<{ + id: number; + email: string; + ticket_type: number; + votes_allowed: number; + quantity: number; + }>(); + return results[0] || null; + }; + + const clearRefreshToken = async (userId: number) => { + await database + .prepare( + 'UPDATE users SET refresh_token_hash = NULL, refresh_token_expires_at = NULL WHERE id = ?' + ) + .bind(userId) + .run(); + }; + + return { + schema, + findByCode, + votesRemaining, + insert, + insertBatch, + revoke, + getEligibleTalks, + findByEmail, + findById, + setRefreshToken, + findByRefreshTokenHash, + clearRefreshToken, + }; +}; diff --git a/src/server/repositories/vote.ts b/src/server/repositories/vote.ts new file mode 100644 index 0000000..a97f60c --- /dev/null +++ b/src/server/repositories/vote.ts @@ -0,0 +1,33 @@ +import type { Database } from '../types.js'; +import { z } from 'zod'; + +export const vote = (database: Database) => { + const schema = z.object({ + code: z.string().length(4), + talk_id: z.number().int().positive(), + }); + + const hasVoted = async (userId: number, talkId: number) => { + const { results } = await database + .prepare('SELECT id FROM votes WHERE user_id = ? AND talk_id = ?') + .bind(userId, talkId) + .all<{ id: number }>(); + return results.length > 0; + }; + + const cast = async (userId: number, talkId: number) => { + await database + .prepare('INSERT INTO votes (user_id, talk_id) VALUES (?, ?)') + .bind(userId, talkId) + .run(); + }; + + const retract = async (userId: number, talkId: number) => { + await database + .prepare('DELETE FROM votes WHERE user_id = ? AND talk_id = ?') + .bind(userId, talkId) + .run(); + }; + + return { schema, hasVoted, cast, retract }; +}; diff --git a/src/server/routes.ts b/src/server/routes.ts index e17ec77..c777031 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,9 +1,24 @@ +import { login } from './routes/auth/login.js'; +import { logout } from './routes/auth/logout.js'; +import { refresh } from './routes/auth/refresh.js'; +import { requestCode } from './routes/auth/request-code.js'; import { c4p } from './routes/c4p.js'; import { stripeWebhook } from './routes/stripe-webhook.js'; +import { usersBatch } from './routes/users-batch.js'; +import { votingState } from './routes/voting-state.js'; +import { votingRetract, votingVote } from './routes/voting-vote.js'; import { waitlist } from './routes/waitlist.js'; export const routes = { c4p, waitlist, stripeWebhook, + usersBatch, + login, + votingState, + votingVote, + votingRetract, + requestCode, + refresh, + logout, }; diff --git a/src/server/routes/auth/login.ts b/src/server/routes/auth/login.ts new file mode 100644 index 0000000..b98712c --- /dev/null +++ b/src/server/routes/auth/login.ts @@ -0,0 +1,54 @@ +import type { RouteContext } from '../types.js'; +import { setTimeout as wait } from 'node:timers/promises'; +import { z } from 'zod'; +import { parseRequest } from '../../helpers/request.js'; +import { response } from '../../helpers/response.js'; +import { generateTokens, hashRefreshToken } from '../../lib/tokens.js'; +import { user } from '../../repositories/user.js'; + +export { RefreshTokenResponseSchema } from '../../schemas.js'; + +export const voteSchema = z.object({ + code: z.string().length(4), +}); + +export const login = async (ctx: RouteContext) => { + const { request, cors, database, env } = ctx; + + const parsed = await parseRequest(request, voteSchema, cors); + if (parsed instanceof Response) return parsed; + + try { + const usersRepo = user(database); + + const [u] = await Promise.all([ + usersRepo.findByCode(parsed.code), + wait(100), + ]); + + if (!u) return response({ error: 'User not found' }, 404, cors); + + const tokens = await generateTokens( + { id: u.id, email: u.email }, + env.JWT_SECRET + ); + const tokenHash = hashRefreshToken(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await usersRepo.setRefreshToken(u.email, tokenHash, expiresAt); + + return response( + { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user: { + id: u.id, + email: u.email, + }, + }, + 200, + cors + ); + } catch { + return response({ error: 'Internal error' }, 500, cors); + } +}; diff --git a/src/server/routes/auth/logout.ts b/src/server/routes/auth/logout.ts new file mode 100644 index 0000000..fac05fe --- /dev/null +++ b/src/server/routes/auth/logout.ts @@ -0,0 +1,33 @@ +import type { RouteContext } from '../types.js'; +import { z } from 'zod'; +import { parseRequest } from '../../helpers/request.js'; +import { response } from '../../helpers/response.js'; +import { hashRefreshToken } from '../../lib/tokens.js'; +import { user } from '../../repositories/user.js'; + +export { LogoutResponseSchema } from '../../schemas.js'; + +export const logoutSchema = z.object({ + refresh_token: z.string(), +}); + +export const logout = async (ctx: RouteContext) => { + const { request, cors, database } = ctx; + + const parsed = await parseRequest(request, logoutSchema, cors); + if (parsed instanceof Response) { + return response({ error: 'invalid_refresh_token' }, 401, cors); + } + + try { + const usersRepo = user(database); + const hashedToken = hashRefreshToken(parsed.refresh_token); + const u = await usersRepo.findByRefreshTokenHash(hashedToken); + if (!u) return response({ error: 'invalid_refresh_token' }, 401, cors); + + await usersRepo.clearRefreshToken(u.id); + return response({ success: true }, 200, cors); + } catch { + return response({ error: 'Internal error' }, 500, cors); + } +}; diff --git a/src/server/routes/auth/refresh.ts b/src/server/routes/auth/refresh.ts new file mode 100644 index 0000000..4cfc5fe --- /dev/null +++ b/src/server/routes/auth/refresh.ts @@ -0,0 +1,51 @@ +import type { RouteContext } from '../types.js'; +import { z } from 'zod'; +import { parseRequest } from '../../helpers/request.js'; +import { response } from '../../helpers/response.js'; +import { generateTokens, hashRefreshToken } from '../../lib/tokens.js'; +import { user } from '../../repositories/user.js'; + +export { RefreshTokenResponseSchema } from '../../schemas.js'; + +export const refreshTokenSchema = z.object({ + refresh_token: z.string(), +}); + +export const refresh = async (ctx: RouteContext) => { + const { request, cors, database, env } = ctx; + + const parsed = await parseRequest(request, refreshTokenSchema, cors); + if (parsed instanceof Response) { + return response({ error: 'invalid_refresh_token' }, 401, cors); + } + + try { + const usersRepo = user(database); + const hashedToken = hashRefreshToken(parsed.refresh_token); + const u = await usersRepo.findByRefreshTokenHash(hashedToken); + if (!u) return response({ error: 'invalid_refresh_token' }, 401, cors); + + const tokens = await generateTokens( + { id: u.id, email: u.email }, + env.JWT_SECRET + ); + const newHash = hashRefreshToken(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await usersRepo.setRefreshToken(u.email, newHash, expiresAt); + + return response( + { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user: { + id: u.id, + email: u.email, + }, + }, + 200, + cors + ); + } catch { + return response({ error: 'Internal error' }, 500, cors); + } +}; diff --git a/src/server/routes/auth/request-code.ts b/src/server/routes/auth/request-code.ts new file mode 100644 index 0000000..88e5088 --- /dev/null +++ b/src/server/routes/auth/request-code.ts @@ -0,0 +1,30 @@ +import type { RouteContext } from '../types.js'; +import { setTimeout as wait } from 'node:timers/promises'; +import { z } from 'zod'; +import { parseRequest } from '../../helpers/request.js'; +import { response } from '../../helpers/response.js'; +import { user } from '../../repositories/user.js'; + +export const requestCodeSchema = z.object({ + email: z.email(), +}); + +export const requestCode = async (ctx: RouteContext) => { + const parsed = await parseRequest(ctx.request, requestCodeSchema, ctx.cors); + if (parsed instanceof Response) return parsed; + + const { email } = parsed; + + const usersRepo = user(ctx.database); + const [u] = await Promise.all([usersRepo.findByEmail(email), wait(100)]); + + if (!u) { + console.warn(`[requestCode] No user found for email: ${email}`); + return response({ success: true }, 200, ctx.cors); + } + + const sender = ctx.sender; + await sender.sendCode(u.email, u.code); + + return response({ success: true }, 200, ctx.cors); +}; diff --git a/src/server/routes/c4p.ts b/src/server/routes/c4p.ts index a24bb3a..27a653f 100644 --- a/src/server/routes/c4p.ts +++ b/src/server/routes/c4p.ts @@ -1,9 +1,6 @@ import type { Database } from '../types.js'; -import { - isJsonContentType, - isWithinSize, - parseBody, -} from '../helpers/request.js'; +import type { RouteContext } from './types.js'; +import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; import { c4p as repository } from '../repositories/c4p.js'; @@ -19,27 +16,17 @@ export const c4p = async ({ cors, database, ip, -}: Options): Promise => { - if (!isJsonContentType(request)) - return response({ error: 'Unsupported content type.' }, 415, cors); - - const text = await request.text(); - if (!isWithinSize(text, 16384)) - return response({ error: 'Payload too large.' }, 413, cors); - - const body = parseBody(text); - if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); - +}: RouteContext & Options): Promise => { const { schema, submit } = repository(database); - const parsed = schema.safeParse(body); - if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + const parsed = await parseRequest(request, schema, cors); + if (parsed instanceof Response) return parsed; // Bot Honeypot - if (parsed.data.confirm_email.length > 0) + if (parsed.confirm_email.length > 0) return response({ success: true }, 201, cors); - const result = await submit(parsed.data, ip); + const result = await submit(parsed, ip); if (!result.success) { if (result.reason === 'talk_limit') diff --git a/src/server/routes/stripe-webhook.ts b/src/server/routes/stripe-webhook.ts index 9f761a2..5f36ca6 100644 --- a/src/server/routes/stripe-webhook.ts +++ b/src/server/routes/stripe-webhook.ts @@ -1,21 +1,95 @@ import type Stripe from 'stripe'; -import type { Env } from '../types.js'; +import type { Database, Env } from '../types.js'; +import { z } from 'zod'; import { createStripeClient, cryptoProvider } from '../configs/stripe.js'; import { response } from '../helpers/response.js'; +import { user } from '../repositories/user.js'; type Options = { request: Request; cors: Record; env: Env; + database: Database; }; -export const handleEvent = (event: Stripe.Event): boolean => { +// need to update this with the actual date in the future +const EARLY_BIRD_CUTOFF = new Date('2026-12-31T23:59:59Z').getTime() / 1000; + +const TICKET_WEIGHTS: Record = { + 1: 1, + 2: 3, +}; + +export type ExtractedUserData = { + email: string; + ticketType: number; + quantity: number; + votesAllowed: number; +}; + +export const chargeRefundedSchema = z.object({ + billing_details: z + .object({ + email: z.string().nullable(), + }) + .nullable(), + receipt_email: z.string().nullable(), + payment_intent: z.string().nullable(), +}); + +export const extractUserData = ( + session: Stripe.Checkout.Session +): ExtractedUserData | null => { + const email = session.customer_email ?? null; + if (!email) return null; + + const created = typeof session.created === 'number' ? session.created : 0; + const ticketType = created <= EARLY_BIRD_CUTOFF ? 2 : 1; + + const rawQuantity = session.metadata?.['quantity']; + const quantity = rawQuantity ? parseInt(rawQuantity, 10) : 1; + if (isNaN(quantity) || quantity < 1) return null; + + const weight = TICKET_WEIGHTS[ticketType] ?? 0; + const votesAllowed = quantity * weight; + if (votesAllowed === 0) return null; + + return { email, ticketType, quantity, votesAllowed }; +}; + +export const handleEvent = async ( + event: Stripe.Event, + database: Database +): Promise => { switch (event.type) { - case 'checkout.session.completed': + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const data = extractUserData(session); + if (!data) return true; + + await user(database).insertBatch([ + { + email: data.email, + ticket_type: data.ticketType, + votes_allowed: data.votesAllowed, + quantity: data.quantity, + }, + ]); + return true; + } case 'payment_intent.succeeded': case 'charge.succeeded': - case 'charge.refunded': return true; + case 'charge.refunded': { + const charge = event.data.object as Stripe.Charge; + const email = + charge.billing_details?.email ?? charge.receipt_email ?? null; + + if (!email) return true; + + await user(database).revoke(email); + return true; + } default: return false; } @@ -25,6 +99,7 @@ export const stripeWebhook = async ({ request, cors, env, + database, }: Options): Promise => { const signature = request.headers.get('stripe-signature'); if (!signature) return response({ error: 'Missing signature.' }, 400, cors); @@ -41,7 +116,7 @@ export const stripeWebhook = async ({ cryptoProvider ); - handleEvent(event); + await handleEvent(event, database); return response({ received: true }, 200, cors); } catch { diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts new file mode 100644 index 0000000..141abbd --- /dev/null +++ b/src/server/routes/types.ts @@ -0,0 +1,10 @@ +import type { CodeSender } from '../code-sender.js'; +import type { Database, Env } from '../types.js'; + +export interface RouteContext { + request: Request; + cors: Record; + database: Database; + env: Env; + sender: CodeSender; +} diff --git a/src/server/routes/users-batch.ts b/src/server/routes/users-batch.ts new file mode 100644 index 0000000..a52915c --- /dev/null +++ b/src/server/routes/users-batch.ts @@ -0,0 +1,29 @@ +import type { RouteContext } from './types.js'; +import { z } from 'zod'; +import { parseRequest } from '../helpers/request.js'; +import { response } from '../helpers/response.js'; +import { user, userSchema } from '../repositories/user.js'; + +export { + UsersBatchResultSchema, + UsersBatchUpsertResultSchema, +} from '../schemas.js'; + +export const usersBatchBodySchema = z.object({ + users: z.array(userSchema).min(1), +}); + +export const usersBatch = async (ctx: RouteContext) => { + const { request, cors, database, env } = ctx; + + const parsed = await parseRequest(request, usersBatchBodySchema, cors); + if (parsed instanceof Response) return parsed; + + const adminKey = request.headers.get('ADMIN_KEY'); + if (!adminKey || adminKey !== env.ADMIN_KEY) + return response({ error: 'Unauthorized' }, 401, cors); + + const usersRepo = user(database); + const results = await usersRepo.insertBatch(parsed.users); + return response({ results }, 201, cors); +}; diff --git a/src/server/routes/voting-state.ts b/src/server/routes/voting-state.ts new file mode 100644 index 0000000..0360bb3 --- /dev/null +++ b/src/server/routes/voting-state.ts @@ -0,0 +1,37 @@ +import type { RouteContext } from './types.js'; +import { response } from '../helpers/response.js'; +import { requireAuth } from '../middleware/auth.js'; +import { user } from '../repositories/user.js'; +import { VotingStateResponseSchema } from '../schemas.js'; + +export { VotingStateResponseSchema }; + +export const votingState = async (ctx: RouteContext) => { + const auth = await requireAuth(ctx.request, ctx.env, ctx.database, ctx.cors); + if (auth instanceof Response) return auth; + + const usersRepo = user(ctx.database); + const votesCast = await usersRepo.votesRemaining(auth.user.id); + const talks = await usersRepo.getEligibleTalks(auth.user.id); + + return response( + { + user: { + id: auth.user.id, + email: auth.user.email, + votes_remaining: auth.user.votes_allowed - votesCast, + }, + talks: talks.map((t) => ({ + id: t.id, + title: t.title, + description: t.description, + speaker_name: t.speaker_name, + duration: t.duration, + audience_level: t.audience_level, + })), + votedTalkIds: talks.filter((t) => t.has_voted === 1).map((t) => t.id), + }, + 200, + ctx.cors + ); +}; diff --git a/src/server/routes/voting-vote.ts b/src/server/routes/voting-vote.ts new file mode 100644 index 0000000..23d865c --- /dev/null +++ b/src/server/routes/voting-vote.ts @@ -0,0 +1,64 @@ +import type { RouteContext } from './types.js'; +import { z } from 'zod'; +import { parseRequest } from '../helpers/request.js'; +import { response } from '../helpers/response.js'; +import { requireAuth } from '../middleware/auth.js'; +import { user } from '../repositories/user.js'; +import { vote } from '../repositories/vote.js'; + +export { VotesRemainingResponseSchema } from '../schemas.js'; + +export const voteOpSchema = z.object({ + talk_id: z.number().int().positive(), +}); + +export const votingVote = async (ctx: RouteContext) => { + const { request, cors, database, env } = ctx; + + const auth = await requireAuth(request, env, database, cors); + if (auth instanceof Response) return auth; + const u = auth.user; + + const parsed = await parseRequest(request, voteOpSchema, cors); + if (parsed instanceof Response) return parsed; + + const usersRepo = user(database); + const votesCast = await usersRepo.votesRemaining(u.id); + if (votesCast >= u.votes_allowed) + return response({ error: 'No votes remaining' }, 403, cors); + + const voteRepo = vote(database); + + const alreadyVoted = await voteRepo.hasVoted(u.id, parsed.talk_id); + if (alreadyVoted) + return response({ error: 'Already voted for this talk' }, 409, cors); + + await voteRepo.cast(u.id, parsed.talk_id); + + return response( + { votes_remaining: u.votes_allowed - votesCast - 1 }, + 200, + cors + ); +}; + +export const votingRetract = async (ctx: RouteContext) => { + const { request, cors, database, env } = ctx; + + const auth = await requireAuth(request, env, database, cors); + if (auth instanceof Response) return auth; + const u = auth.user; + + const parsed = await parseRequest(request, voteOpSchema, cors); + if (parsed instanceof Response) return parsed; + + const voteRepo = vote(database); + const alreadyVoted = await voteRepo.hasVoted(u.id, parsed.talk_id); + if (!alreadyVoted) return response({ error: 'Vote not found' }, 404, cors); + + await voteRepo.retract(u.id, parsed.talk_id); + const usersRepo = user(database); + const votesCast = await usersRepo.votesRemaining(u.id); + + return response({ votes_remaining: u.votes_allowed - votesCast }, 200, cors); +}; diff --git a/src/server/routes/waitlist.ts b/src/server/routes/waitlist.ts index 8791f4c..00c1acf 100644 --- a/src/server/routes/waitlist.ts +++ b/src/server/routes/waitlist.ts @@ -1,9 +1,6 @@ import type { Database } from '../types.js'; -import { - isJsonContentType, - isWithinSize, - parseBody, -} from '../helpers/request.js'; +import type { RouteContext } from './types.js'; +import { parseRequest } from '../helpers/request.js'; import { response } from '../helpers/response.js'; import { waitlist as repository } from '../repositories/waitlist.js'; @@ -19,23 +16,13 @@ export const waitlist = async ({ cors, database, ip, -}: Options): Promise => { - if (!isJsonContentType(request)) - return response({ error: 'Unsupported content type.' }, 415, cors); - - const text = await request.text(); - if (!isWithinSize(text, 1024)) - return response({ error: 'Payload too large.' }, 413, cors); - - const body = parseBody(text); - if (!body) return response({ error: 'Invalid JSON.' }, 400, cors); - +}: RouteContext & Options): Promise => { const { schema, canInsert, insert } = repository(database); - const parsed = schema.safeParse(body); - if (!parsed.success) return response({ error: 'Invalid input.' }, 422, cors); + const parsed = await parseRequest(request, schema, cors); + if (parsed instanceof Response) return parsed; - const { email, website, utmSource } = parsed.data; + const { email, website, utmSource } = parsed; // Bot Honeypot if (website.length > 0) return response({ success: true }, 201, cors); diff --git a/src/server/schemas.ts b/src/server/schemas.ts new file mode 100644 index 0000000..556d4d3 --- /dev/null +++ b/src/server/schemas.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; + +// --- Response schemas (shared across routes) --- + +export const UserSchema = z.object({ + id: z.number(), + email: z.string(), + ticket_type: z.number(), + votes_allowed: z.number(), + votes_remaining: z.number(), + quantity: z.number(), + code: z.string(), +}); + +export const TalkSchema = z.object({ + id: z.number(), + title: z.string(), + description: z.string(), + speaker_name: z.string(), + duration: z.number(), + audience_level: z.number(), +}); + +export const VotingAuthResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + user: z.object({ + id: z.number(), + email: z.string(), + }), +}); + +export const VotingAuthUserOnlySchema = z.object({ + user: z.object({ + id: z.number(), + email: z.string(), + votes_remaining: z.number(), + }), + talks: z.array(TalkSchema), +}); + +export const UsersBatchResultItemSchema = z.object({ + email: z.string(), + code: z.string(), +}); + +export const UsersBatchResultSchema = z.object({ + results: z.array(UsersBatchResultItemSchema), +}); + +export const UsersBatchUpsertResultSchema = UsersBatchResultSchema; + +export const VotesRemainingResponseSchema = z.object({ + votes_remaining: z.number(), +}); + +export const RefreshTokenRequestSchema = z.object({ + refresh_token: z.string(), +}); + +export const RefreshTokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + user: z.object({ + id: z.number(), + email: z.string(), + }), +}); + +export const VotingStateResponseSchema = z.object({ + user: z.object({ + id: z.number(), + email: z.string(), + votes_remaining: z.number(), + }), + talks: z.array(TalkSchema), + votedTalkIds: z.array(z.number()), +}); + +export const LogoutRequestSchema = z.object({ + refresh_token: z.string(), +}); + +export const LogoutResponseSchema = z.object({ + success: z.boolean(), +}); diff --git a/src/server/types.ts b/src/server/types.ts index 164824e..395e439 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -12,4 +12,7 @@ export type Env = { ALLOWED_ORIGIN?: string; STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string; + ADMIN_KEY: string; + JWT_SECRET: string; + ENVIRONMENT?: string; }; diff --git a/src/website/components/home/VotingCta.tsx b/src/website/components/home/VotingCta.tsx new file mode 100644 index 0000000..47774f6 --- /dev/null +++ b/src/website/components/home/VotingCta.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { RefreshCw, Vote } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; +import { VotingAuthModal } from '@site/src/website/components/voting/VotingAuthModal'; +import { useVotingAuth } from '@site/src/website/hooks/voting/useVotingAuth'; + +export const VotingCta = () => { + const [modalOpen, setModalOpen] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const { tryAutoRefresh } = useVotingAuth(); + + const handleClick = async () => { + setIsRefreshing(true); + const ok = await tryAutoRefresh(); + setIsRefreshing(false); + + if (ok) { + window.location.href = '/voting'; + } else { + setModalOpen(true); + } + }; + + return ( + <> +
+
+
+ + +
+

+ +

+ +
+
+ + {modalOpen && setModalOpen(false)} />} + + ); +}; diff --git a/src/website/components/voting/TalkCard.tsx b/src/website/components/voting/TalkCard.tsx new file mode 100644 index 0000000..59cd9ac --- /dev/null +++ b/src/website/components/voting/TalkCard.tsx @@ -0,0 +1,78 @@ +import type { Talk } from '@site/src/website/hooks/voting/useVoting'; +import { Clock, ThumbsDown, ThumbsUp, Users } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; + +type TalkCardProps = { + talk: Talk; + hasVoted: boolean; + canVote: boolean; + onVote: () => void; + onRetract: () => void; + loading: boolean; +}; + +const DURATION_LABELS = ['15 min', '25 min']; +const LEVEL_LABELS = ['Iniciante', 'Intermediário', 'Avançado']; + +export const TalkCard = ({ + talk, + hasVoted, + canVote, + onVote, + onRetract, + loading, +}: TalkCardProps) => { + return ( +
+
+

{talk.title}

+
+ + + {talk.speaker_name} + + + + {DURATION_LABELS[talk.duration] ?? `${talk.duration} min`} + + + {LEVEL_LABELS[talk.audience_level - 1] ?? + `Nível ${talk.audience_level}`} + +
+
+ +

{talk.description}

+ +
+ {hasVoted ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/website/components/voting/TalkDetailModal.tsx b/src/website/components/voting/TalkDetailModal.tsx new file mode 100644 index 0000000..16a9f59 --- /dev/null +++ b/src/website/components/voting/TalkDetailModal.tsx @@ -0,0 +1,105 @@ +import type { Talk } from '@site/src/website/hooks/voting/useVoting'; +import { useEffect } from 'react'; +import { Clock, ThumbsDown, ThumbsUp, Users, X } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; + +type TalkDetailModalProps = { + talk: Talk | null; + canVote: boolean; + hasVoted: boolean; + onVote: () => void; + onRetract: () => void; + onClose: () => void; +}; + +const DURATION_LABELS = ['15 min', '25 min']; +const LEVEL_LABELS = ['Iniciante', 'Intermediário', 'Avançado']; + +export const TalkDetailModal = ({ + talk, + canVote, + hasVoted, + onVote, + onRetract, + onClose, +}: TalkDetailModalProps) => { + useEffect(() => { + if (!talk) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [talk, onClose]); + + if (!talk) return null; + + return ( +
+
{ + e.stopPropagation(); + }} + > + + +
+

{talk.title}

+ + {LEVEL_LABELS[talk.audience_level - 1] ?? + `Nível ${talk.audience_level}`} + +
+ +
+ + + {talk.speaker_name} + + + + {DURATION_LABELS[talk.duration] ?? `${talk.duration} min`} + +
+ +

{talk.description}

+ + {hasVoted ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/website/components/voting/VotingAuthModal.tsx b/src/website/components/voting/VotingAuthModal.tsx new file mode 100644 index 0000000..e25c9fb --- /dev/null +++ b/src/website/components/voting/VotingAuthModal.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { X } from 'lucide-react'; +import { Text, text } from '@site/src/website/components/shared/i18n'; +import { useVotingAuth } from '@site/src/website/hooks/voting/useVotingAuth'; + +type VotingAuthModalProps = { + onClose: () => void; +}; + +type Step = 1 | 2; + +export const VotingAuthModal = ({ onClose }: VotingAuthModalProps) => { + const [step, setStep] = useState(1); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const { state, error, requestCode, authenticate, reset } = useVotingAuth(); + + const handleEmailSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const ok = await requestCode(email); + if (ok) setStep(2); + }; + + const handleCodeSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await authenticate(code); + }; + + const handleBack = () => { + reset(); + setStep(1); + setEmail(''); + setCode(''); + }; + + return ( +
+
e.stopPropagation()}> + + +
+

+ {step === 1 ? ( + + ) : ( + + )} +

+
+ + {step === 1 && ( +
+
+ + setEmail(e.target.value)} + placeholder={text({ + id: 'voting.modal.step1.email.placeholder', + })} + required + className='voting-input' + disabled={state === 'loading'} + /> +
+ + {error &&

{error}

} + {state === 'success' && ( +

+ +

+ )} + + +
+ )} + + {step === 2 && ( +
+
+ + setCode(e.target.value.replace(/\f/g, ''))} + placeholder={text({ + id: 'voting.modal.step2.code.placeholder', + })} + required + className='voting-input voting-input-code' + disabled={state === 'loading'} + autoFocus + /> +
+ + {error &&

{error}

} + + + + +
+ )} +
+
+ ); +}; diff --git a/src/website/components/voting/VotingPage.tsx b/src/website/components/voting/VotingPage.tsx new file mode 100644 index 0000000..b7c184a --- /dev/null +++ b/src/website/components/voting/VotingPage.tsx @@ -0,0 +1,145 @@ +import type { Talk } from '@site/src/website/hooks/voting/useVoting'; +import { useState } from 'react'; +import { RefreshCw, Vote } from 'lucide-react'; +import { Text } from '@site/src/website/components/shared/i18n'; +import { Page } from '@site/src/website/components/shared/Page'; +import { TalkCard } from '@site/src/website/components/voting/TalkCard'; +import { TalkDetailModal } from '@site/src/website/components/voting/TalkDetailModal'; +import { useVoting } from '@site/src/website/hooks/voting/useVoting'; + +const VotingContent = () => { + const { + state, + user, + talks, + votedTalkIds, + error, + castVote, + retractVote, + refetch, + } = useVoting(); + const [pendingTalkId, setPendingTalkId] = useState(null); + const [selectedTalk, setSelectedTalk] = useState(null); + + if (state === 'loading') { + return ( +
+
+ +
+ ); + } + + if (state === 'error') { + return ( +
+

{error}

+ +
+ ); + } + + return ( + <> +
+
+ {user?.email} + + + +
+
+ + {user?.votes_remaining === 0 && ( +
+ +
+ )} + + {talks.length === 0 ? ( +
+ +
+ ) : ( +
+ {talks.map((talk) => ( +
{ + setSelectedTalk(talk); + }} + role='button' + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') setSelectedTalk(talk); + }} + > + 0} + onVote={async () => { + setPendingTalkId(talk.id); + await castVote(talk.id); + setPendingTalkId(null); + }} + onRetract={async () => { + setPendingTalkId(talk.id); + await retractVote(talk.id); + setPendingTalkId(null); + }} + loading={pendingTalkId === talk.id} + /> +
+ ))} +
+ )} + + 0} + hasVoted={selectedTalk ? votedTalkIds.has(selectedTalk.id) : false} + onVote={async () => { + if (!selectedTalk) return; + setPendingTalkId(selectedTalk.id); + await castVote(selectedTalk.id); + setPendingTalkId(null); + }} + onRetract={async () => { + if (!selectedTalk) return; + setPendingTalkId(selectedTalk.id); + await retractVote(selectedTalk.id); + setPendingTalkId(null); + }} + onClose={() => { + setSelectedTalk(null); + }} + /> + + ); +}; + +export const VotingPage = () => ( + +
+

+ + +

+ +
+
+); diff --git a/src/website/hooks/voting/useVoting.ts b/src/website/hooks/voting/useVoting.ts new file mode 100644 index 0000000..86747c0 --- /dev/null +++ b/src/website/hooks/voting/useVoting.ts @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; +import { clearSession, getStoredSession } from './useVotingAuth'; + +export type Talk = { + id: number; + title: string; + description: string; + speaker_name: string; + duration: number; + audience_level: number; +}; + +export type UserInfo = { + id: number; + email: string; + votes_remaining: number; +}; + +type VotingState = 'loading' | 'authenticated' | 'error'; + +type StateResponse = { + user: UserInfo; + talks: Talk[]; + votedTalkIds: number[]; +}; + +const WORKER_DOMAIN = 'http://localhost:8787'; + +export type UseVotingResult = { + state: VotingState; + user: UserInfo | null; + talks: Talk[]; + votedTalkIds: Set; + error: string | null; + castVote: (talkId: number) => Promise; + retractVote: (talkId: number) => Promise; + refetch: () => Promise; +}; + +const fetchState = async ( + token: string, + domain: string +): Promise => { + try { + const res = await fetch(`${domain}/api/voting/state`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + return (await res.json()) as StateResponse; + } catch { + return null; + } +}; + +const storeAndReturn = (data: { + access_token: string; + refresh_token: string; +}) => { + if (typeof window !== 'undefined') { + const existing = getStoredSession(); + localStorage.setItem( + 'voting_session', + JSON.stringify({ + access_token: data.access_token, + refresh_token: data.refresh_token, + user: existing?.user ?? { id: 0, email: '', votes_remaining: 0 }, + }) + ); + } +}; + +export const useVoting = (): UseVotingResult => { + const [state, setState] = useState('loading'); + const [user, setUser] = useState(null); + const [talks, setTalks] = useState([]); + const [votedTalkIds, setVotedTalkIds] = useState>(new Set()); + const [error, setError] = useState(null); + + const { siteConfig } = useDocusaurusContext(); + const domain = + (siteConfig.customFields?.['workerDomain'] as string | undefined) ?? + WORKER_DOMAIN; + + const tryRefresh = useCallback(async (): Promise => { + const session = getStoredSession(); + if (!session?.refresh_token) return false; + + try { + const res = await fetch(`${domain}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: session.refresh_token }), + }); + if (!res.ok) return false; + + const data = (await res.json()) as { + access_token: string; + refresh_token: string; + }; + + storeAndReturn(data); + return true; + } catch { + return false; + } + }, [domain]); + + const loadState = useCallback( + async (token: string): Promise => { + const data = await fetchState(token, domain); + if (!data) return false; + + if (typeof window !== 'undefined') { + const existing = getStoredSession(); + if (existing) { + localStorage.setItem( + 'voting_session', + JSON.stringify({ ...existing, user: data.user }) + ); + } + } + + setUser(data.user); + setTalks(data.talks); + setVotedTalkIds(new Set(data.votedTalkIds)); + return true; + }, + [domain] + ); + + const refetch = useCallback(async () => { + setState('loading'); + setError(null); + + const session = getStoredSession(); + if (!session) { + setError('Please authenticate first'); + setState('error'); + return; + } + + const loaded = await loadState(session.access_token); + if (loaded) { + setState('authenticated'); + return; + } + + const refreshed = await tryRefresh(); + if (refreshed) { + const newSession = getStoredSession(); + if (newSession && (await loadState(newSession.access_token))) { + setState('authenticated'); + return; + } + } + + clearSession(); + const msg = text({ id: 'voting.page.error.generic' }); + setError(msg); + setState('error'); + }, [loadState, tryRefresh]); + + useEffect(() => { + refetch(); + }, [refetch]); + + const withTokenRefresh = useCallback( + async ( + fn: (token: string) => Promise<{ ok: boolean; data?: T; status: number }> + ): Promise => { + const session = getStoredSession(); + if (!session) return null; + + let result = await fn(session.access_token); + + if (result.status === 401) { + const refreshed = await tryRefresh(); + if (!refreshed) { + clearSession(); + window.location.href = '/'; + return null; + } + const newSession = getStoredSession(); + if (!newSession) { + clearSession(); + window.location.href = '/'; + return null; + } + result = await fn(newSession.access_token); + } + + return result.ok && result.data ? result.data : null; + }, + [tryRefresh] + ); + + const castVote = useCallback( + async (talkId: number): Promise => { + const makeRequest = async (token: string) => { + const response = await fetch(`${domain}/api/voting/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ talk_id: talkId }), + }); + const data = response.ok + ? ((await response.json()) as { votes_remaining: number }) + : undefined; + return { ok: response.ok, data, status: response.status }; + }; + + const result = await withTokenRefresh(makeRequest); + if (result === null) { + toast.error(text({ id: 'voting.page.error.voteFailed' })); + return false; + } + + if (!result) return false; + + setUser((prev) => + prev ? { ...prev, votes_remaining: result.votes_remaining } : prev + ); + setVotedTalkIds((prev) => new Set([...prev, talkId])); + toast.success(text({ id: 'voting.page.voteSuccess' })); + return true; + }, + [domain, withTokenRefresh] + ); + + const retractVote = useCallback( + async (talkId: number): Promise => { + const makeRequest = async (token: string) => { + const response = await fetch(`${domain}/api/voting/vote`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ talk_id: talkId }), + }); + const data = response.ok + ? ((await response.json()) as { votes_remaining: number }) + : undefined; + return { ok: response.ok, data, status: response.status }; + }; + + const result = await withTokenRefresh(makeRequest); + if (result === null) { + toast.error(text({ id: 'voting.page.error.retractFailed' })); + return false; + } + + if (!result) return false; + + setUser((prev) => + prev ? { ...prev, votes_remaining: result.votes_remaining } : prev + ); + setVotedTalkIds((prev) => { + const next = new Set(prev); + next.delete(talkId); + return next; + }); + toast.success(text({ id: 'voting.page.retractSuccess' })); + return true; + }, + [domain, withTokenRefresh] + ); + + return { + state, + user, + talks, + votedTalkIds, + error, + castVote, + retractVote, + refetch, + }; +}; diff --git a/src/website/hooks/voting/useVotingAuth.ts b/src/website/hooks/voting/useVotingAuth.ts new file mode 100644 index 0000000..f6e93be --- /dev/null +++ b/src/website/hooks/voting/useVotingAuth.ts @@ -0,0 +1,193 @@ +import { useCallback, useState } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { toast } from 'sonner'; +import { text } from '@site/src/website/components/shared/i18n'; + +export type Talk = { + id: number; + title: string; + description: string; + speaker_name: string; + duration: number; + audience_level: number; +}; + +export type UserInfo = { + id: number; + email: string; + votes_remaining: number; +}; + +export interface StoredSession { + access_token: string; + refresh_token: string; + user: UserInfo; +} + +type AuthState = 'idle' | 'loading' | 'success' | 'error' | 'authenticated'; + +export type UseVotingAuthResult = { + state: AuthState; + error: string | null; + requestCode: (email: string) => Promise; + authenticate: (code: string) => Promise; + reset: () => void; + tryAutoRefresh: () => Promise; +}; + +const WORKER_DOMAIN = 'http://localhost:8787'; +const STORAGE_KEY = 'voting_session'; + +export function getStoredSession(): StoredSession | null { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as StoredSession; + } catch { + return null; + } +} + +export function clearSession(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(STORAGE_KEY); +} + +export const useVotingAuth = (): UseVotingAuthResult => { + const [state, setState] = useState('idle'); + const [error, setError] = useState(null); + + const { siteConfig } = useDocusaurusContext(); + const domain = + (siteConfig.customFields?.['workerDomain'] as string | undefined) ?? + WORKER_DOMAIN; + + const requestCode = useCallback( + async (email: string): Promise => { + setState('loading'); + setError(null); + + try { + const response = await fetch(`${domain}/api/auth/request-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; + const msg = data?.error ?? text({ id: 'voting.modal.step1.error' }); + setError(msg); + setState('error'); + toast.error(msg); + return false; + } + + setState('success'); + return true; + } catch { + const msg = text({ id: 'voting.modal.step1.error' }); + setError(msg); + setState('error'); + toast.error(msg); + return false; + } + }, + [domain] + ); + + const authenticate = useCallback( + async (code: string): Promise => { + setState('loading'); + setError(null); + + try { + const response = await fetch(`${domain}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + let msg: string; + if (response.status === 401 || response.status === 404) { + msg = text({ id: 'voting.modal.step2.error.notFound' }); + } else { + const data = (await response.json().catch(() => null)) as { + error?: string; + } | null; + msg = + data?.error ?? text({ id: 'voting.modal.step2.error.invalid' }); + } + setError(msg); + setState('error'); + toast.error(msg); + return false; + } + + const data = (await response.json()) as StoredSession; + + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + + setState('authenticated'); + + const targetUrl = `${window.location.origin}/voting`; + window.location.href = targetUrl; + return true; + } catch { + const msg = text({ id: 'voting.modal.step2.error.invalid' }); + setError(msg); + setState('error'); + toast.error(msg); + return false; + } + }, + [domain] + ); + + const reset = useCallback(() => { + setState('idle'); + setError(null); + }, []); + + const tryAutoRefresh = useCallback(async (): Promise => { + const session = getStoredSession(); + if (!session) return false; + + try { + setState('loading'); + + const response = await fetch(`${domain}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: session.refresh_token }), + }); + + if (!response.ok) { + clearSession(); + setState('idle'); + return false; + } + + const data = (await response.json()) as StoredSession; + + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + + setState('authenticated'); + return true; + } catch { + clearSession(); + setState('idle'); + return false; + } + }, [domain]); + + return { state, error, requestCode, authenticate, reset, tryAutoRefresh }; +}; diff --git a/src/website/pages/index.tsx b/src/website/pages/index.tsx index 70d14ff..96561f7 100644 --- a/src/website/pages/index.tsx +++ b/src/website/pages/index.tsx @@ -5,6 +5,7 @@ import { Location } from '../components/home/Location'; // import { Speakers } from '../components/home/Speakers'; import { Team } from '../components/home/Team'; import { TicketSelection } from '../components/home/TicketSelection'; +import { VotingCta } from '../components/home/VotingCta'; import { Waitlist } from '../components/home/Waitlist'; import { Page } from '../components/shared/Page'; @@ -15,6 +16,7 @@ export default () => ( {/* */} + diff --git a/src/website/pages/voting/index.tsx b/src/website/pages/voting/index.tsx new file mode 100644 index 0000000..3bc048d --- /dev/null +++ b/src/website/pages/voting/index.tsx @@ -0,0 +1,6 @@ +import { VotingPage } from '@site/src/website/components/voting/VotingPage'; + +export default () => { + // Tokens are in localStorage; VotingPage handles auth state + return ; +}; diff --git a/src/website/scss/pages/voting/_modal.scss b/src/website/scss/pages/voting/_modal.scss new file mode 100644 index 0000000..39de4bd --- /dev/null +++ b/src/website/scss/pages/voting/_modal.scss @@ -0,0 +1,613 @@ +.voting-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fadeIn 0.2s ease; +} + +.voting-modal { + background: var(--background-color); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1rem; + padding: 2rem; + width: min(420px, 90vw); + position: relative; + animation: fadeUp 0.3s ease; +} + +.voting-modal-close { + position: absolute; + top: 1rem; + right: 1rem; + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 0.25rem; + display: flex; + transition: color 0.2s; + + &:hover { + color: rgba(255, 255, 255, 1); + } +} + +.voting-modal-header { + margin-bottom: 1.5rem; + + h2 { + font-size: 2rem; + font-weight: 700; + color: var(--title-color); + margin: 0; + } +} + +.voting-modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; + + label { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.7); + } +} + +.voting-input { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + font-size: 1.4rem; + color: #fff; + width: 100%; + transition: border-color 0.2s; + + &::placeholder { + color: rgba(255, 255, 255, 0.3); + } + + &:focus { + outline: none; + border-color: var(--ifm-color-main); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.voting-input-code { + text-align: center; + font-size: 2rem; + letter-spacing: 0.5rem; + font-family: monospace; + + &::placeholder { + letter-spacing: normal; + text-align: left; + } +} + +.voting-submit { + background: var(--ifm-color-main); + color: #000; + border: none; + border-radius: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 1.3rem; + font-weight: 700; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.85; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.voting-back { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 1.2rem; + cursor: pointer; + padding: 0.25rem; + text-align: center; + transition: color 0.2s; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } +} + +.voting-error { + color: #ff6b6b; + font-size: 1.2rem; + margin: 0; +} + +.voting-success { + color: var(--ifm-color-main); + font-size: 1.2rem; + margin: 0; +} + +// Voting CTA section +.voting-cta-section { + padding: 4rem 0; + + .content { + max-width: 700px; + margin: 0 auto; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + } +} + +.voting-cta-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: rgba(141, 248, 34, 0.1); + border: 1px solid rgba(141, 248, 34, 0.3); + color: var(--ifm-color-main); + padding: 0.35rem 0.85rem; + border-radius: 2rem; + font-size: 1.2rem; + font-weight: 600; +} + +.voting-cta-description { + color: rgba(255, 255, 255, 0.7); + font-size: 1.6rem; + margin: 0; + max-width: 500px; +} + +.voting-cta-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--ifm-color-main); + color: #000; + border: none; + border-radius: 0.5rem; + padding: 0.75rem 1.5rem; + font-size: 1.4rem; + font-weight: 700; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.85; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.voting-cta-spinner { + animation: spin 0.8s linear infinite; +} + +// Voting page +.voting-page { + min-height: 80vh; + padding: 3rem 2rem; + max-width: 900px; + margin: 7rem auto 0; +} + +.voting-page-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 3.2rem; + font-weight: 700; + color: var(--title-color); + margin: 0 0 2rem 0; + + svg { + color: var(--ifm-color-main); + } +} + +.voting-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.voting-user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.voting-email { + color: rgba(255, 255, 255, 0.6); + font-size: 1.2rem; +} + +.voting-votes-badge { + background: rgba(141, 248, 34, 0.15); + border: 1px solid rgba(141, 248, 34, 0.3); + color: var(--ifm-color-main); + padding: 0.25rem 0.75rem; + border-radius: 2rem; + font-size: 1.2rem; + font-weight: 600; +} + +.voting-no-votes-banner { + background: rgba(255, 107, 107, 0.1); + border: 1px solid rgba(255, 107, 107, 0.3); + color: #ff6b6b; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + text-align: center; + font-size: 1.2rem; + margin-bottom: 1.5rem; +} + +.voting-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 4rem 0; + color: rgba(255, 255, 255, 0.5); +} + +.voting-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--ifm-color-main); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.voting-error-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 4rem 0; +} + +.voting-error-message { + color: #ff6b6b; + font-size: 1.4rem; + margin: 0; +} + +.voting-retry { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: none; + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.7); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--ifm-color-main); + color: var(--ifm-color-main); + } +} + +.voting-empty { + text-align: center; + color: rgba(255, 255, 255, 0.4); + padding: 3rem 0; +} + +.voting-talks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.25rem; +} + +// Talk card +.talk-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.75rem; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: + border-color 0.2s, + transform 0.2s; + + &:hover { + border-color: rgba(141, 248, 34, 0.2); + transform: translateY(-2px); + } +} + +.talk-card-header { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.talk-title { + font-size: 1.6rem; + font-weight: 700; + color: var(--title-color); + margin: 0; + line-height: 1.3; +} + +.talk-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.talk-meta-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: rgba(255, 255, 255, 0.5); + font-size: 1.1rem; + + svg { + opacity: 0.7; + } +} + +.talk-badge { + background: rgba(141, 248, 34, 0.08); + border: 1px solid rgba(141, 248, 34, 0.15); + color: rgba(141, 248, 34, 0.8); + padding: 0.1rem 0.5rem; + border-radius: 1rem; + font-size: 1rem; + font-weight: 500; +} + +.talk-description { + color: rgba(255, 255, 255, 0.6); + font-size: 1.3rem; + line-height: 1.5; + margin: 0; + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.talk-actions { + display: flex; + gap: 0.5rem; + margin-top: auto; + padding-top: 0.5rem; +} + +.talk-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 1.2rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: opacity 0.2s; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + opacity: 0.85; + } +} + +.talk-btn-vote { + background: var(--ifm-color-main); + color: #000; + + &:disabled { + background: rgba(141, 248, 34, 0.3); + color: rgba(0, 0, 0, 0.4); + } +} + +.talk-btn-retract { + background: rgba(255, 107, 107, 0.15); + border: 1px solid rgba(255, 107, 107, 0.3); + color: #ff6b6b; +} + +// Talk detail modal +.talk-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; + padding: 1rem; +} + +.talk-detail-modal { + background: var(--background-color); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1rem; + padding: 2.4rem; + width: calc(100% - 2rem); + max-width: 540px; + max-height: 85vh; + overflow-y: auto; + position: relative; + display: flex; + flex-direction: column; + gap: 1.2rem; + animation: fadeUp 0.3s ease; +} + +.talk-detail-close { + position: absolute; + inset-block-start: 1rem; + inset-inline-end: 1rem; + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 0.25rem; + display: flex; + transition: color 0.2s; + + &:hover { + color: rgba(255, 255, 255, 1); + } +} + +.talk-detail-header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding-inline-end: 2rem; + + h2 { + font-size: 2rem; + font-weight: 700; + color: var(--title-color); + margin: 0; + line-height: 1.3; + } +} + +.talk-detail-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + margin: 0; +} + +.talk-detail-meta-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: rgba(255, 255, 255, 0.5); + font-size: 1.2rem; + + + .talk-detail-meta-item { + margin-inline-start: 0.75rem; + padding-inline-start: 0.75rem; + border-inline-start: 1px solid rgba(255, 255, 255, 0.15); + } + + svg { + opacity: 0.7; + } +} + +.talk-detail-level { + background: rgba(141, 248, 34, 0.08); + border: 1px solid rgba(141, 248, 34, 0.15); + color: rgba(141, 248, 34, 0.8); + padding: 0.15rem 0.6rem; + border-radius: 1rem; + font-size: 1.1rem; + font-weight: 500; +} + +.talk-detail-description { + color: rgba(255, 255, 255, 0.7); + font-size: 1.4rem; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +} + +.talk-detail-action { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin-top: auto; + font-size: 1.4rem; + font-weight: 700; + cursor: pointer; + border: none; + transition: opacity 0.2s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + opacity: 0.85; + } +} + +.talk-detail-action-vote { + background: var(--ifm-color-main); + color: #000; + + &:disabled { + background: rgba(141, 248, 34, 0.3); + color: rgba(0, 0, 0, 0.4); + } +} + +.talk-detail-action-retract { + background: rgba(255, 107, 107, 0.15); + border: 1px solid rgba(255, 107, 107, 0.3); + color: #ff6b6b; +} diff --git a/src/website/scss/themes.scss b/src/website/scss/themes.scss index cd6df65..98dfc4c 100644 --- a/src/website/scss/themes.scss +++ b/src/website/scss/themes.scss @@ -47,3 +47,4 @@ mark { @include load-css('global/reset'); @include load-css('global/animations'); @include load-css('global/root'); +@include load-css('pages/voting/modal'); diff --git a/test/server/helpers.ts b/test/server/helpers.ts new file mode 100644 index 0000000..7b3b612 --- /dev/null +++ b/test/server/helpers.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export async function parseJsonResponse( + response: Promise, + schema: z.Schema +): Promise { + const res = await response; + const json = await res.json(); + return schema.parse(json); +} + +export async function parseJsonSafe( + response: Promise, + schema: z.Schema +): Promise<{ success: true; data: T } | { success: false; error: string }> { + try { + const res = await response; + const json = await res.json(); + const parsed = schema.parse(json); + return { success: true, data: parsed }; + } catch (e) { + if (e instanceof z.ZodError) { + return { success: false, error: JSON.stringify(e.issues) }; + } + return { success: false, error: String(e) }; + } +} diff --git a/test/server/lib/tokens.test.ts b/test/server/lib/tokens.test.ts new file mode 100644 index 0000000..22cf793 --- /dev/null +++ b/test/server/lib/tokens.test.ts @@ -0,0 +1,108 @@ +import { createHash } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assert, beforeEach, describe, it } from 'poku'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const tokensPath = join(__dirname, '../../../src/server/lib/tokens.js'); + +let importCount = 0; +const importTokens = () => import(`${tokensPath}?${++importCount}`); + +describe('tokens', async () => { + const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + + beforeEach(async () => { + // Dynamic import with query parameter resets module cache between tests + }); + + await it('generateTokens returns access and refresh tokens', async () => { + const { generateTokens } = await importTokens(); + const result = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + + assert.ok(typeof result.access_token === 'string'); + assert.ok(result.access_token.length > 0); + assert.ok(typeof result.refresh_token === 'string'); + assert.ok(result.refresh_token.length > 0); + assert.notEqual(result.access_token, result.refresh_token); + }); + + await it('access token payload contains sub and email', async () => { + const { generateTokens, validateAccessToken } = await importTokens(); + const { access_token } = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + const payload = await validateAccessToken(access_token, TEST_SECRET); + + assert.equal(payload.sub, '1'); + assert.equal(payload.email, 'test@example.com'); + assert.ok(payload.exp > payload.iat); + }); + + await it('validateAccessToken rejects tampered token', async () => { + const { generateTokens, validateAccessToken } = await importTokens(); + const { access_token } = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + + // Tamper with the token by changing one character in the signature portion + const tampered = access_token.slice(0, -5) + 'XXXXX'; + + let threw = false; + try { + await validateAccessToken(tampered, TEST_SECRET); + } catch { + threw = true; + } + + assert.ok(threw); + }); + + await it('access token expires after 15 minutes', async () => { + const { generateTokens, validateAccessToken } = await importTokens(); + const { access_token } = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + const payload = await validateAccessToken(access_token, TEST_SECRET); + + assert.equal(payload.exp - payload.iat, 15 * 60); + }); + + await it('validateRefreshTokenHash returns true for correct hash', async () => { + const { generateTokens, validateRefreshTokenHash } = await importTokens(); + const { refresh_token } = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + + const hash = createHash('sha256').update(refresh_token).digest('hex'); + + assert.equal(validateRefreshTokenHash(refresh_token, hash), true); + }); + + await it('validateRefreshTokenHash returns false for wrong hash', async () => { + const { validateRefreshTokenHash } = await importTokens(); + const wrongHash = createHash('sha256').update('wrong-token').digest('hex'); + + assert.equal(validateRefreshTokenHash('some-token', wrongHash), false); + }); + + await it('rotateRefreshToken returns new refresh token different from old', async () => { + const { generateTokens, rotateRefreshToken } = await importTokens(); + const { refresh_token } = await generateTokens( + { id: 1, email: 'test@example.com' }, + TEST_SECRET + ); + const newToken = rotateRefreshToken(refresh_token); + + assert.notEqual(newToken, refresh_token); + assert.ok(typeof newToken === 'string'); + assert.ok(newToken.length > 0); + }); +}); diff --git a/test/server/middleware/auth.test.ts b/test/server/middleware/auth.test.ts new file mode 100644 index 0000000..c909acc --- /dev/null +++ b/test/server/middleware/auth.test.ts @@ -0,0 +1,97 @@ +import type { Database, Env } from '../../../src/server/types.js'; +import { assert, describe, it } from 'poku'; +import { generateTokens } from '../../../src/server/lib/tokens.js'; + +describe('middleware.auth requireAuth', async () => { + const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + + const mockEnv: Env = { + DB: {} as Database, + JWT_SECRET: TEST_SECRET, + STRIPE_SECRET_KEY: 'test', + STRIPE_WEBHOOK_SECRET: 'test', + ADMIN_KEY: 'test', + }; + + await it('returns 401 when Authorization header is missing', async () => { + const { requireAuth } = + await import('../../../src/server/middleware/auth.js'); + const req = new Request('http://localhost/api/voting/vote', { + method: 'POST', + }); + const result = await requireAuth(req, mockEnv, {} as Database, {}); + assert.ok(result instanceof Response); + const response = result as Response; + assert.equal(response.status, 401); + const json = (await response.json()) as { error: string }; + assert.equal(json.error, 'unauthorized'); + }); + + await it('returns 401 when Authorization header is not Bearer', async () => { + const { requireAuth } = + await import('../../../src/server/middleware/auth.js'); + const req = new Request('http://localhost/api/voting/vote', { + method: 'POST', + headers: { Authorization: 'Basic abc123' }, + }); + const result = await requireAuth(req, mockEnv, {} as Database, {}); + assert.ok(result instanceof Response); + const response = result as Response; + assert.equal(response.status, 401); + const json = (await response.json()) as { error: string }; + assert.equal(json.error, 'unauthorized'); + }); + + await it('returns 401 when token is malformed', async () => { + const { requireAuth } = + await import('../../../src/server/middleware/auth.js'); + const req = new Request('http://localhost/api/voting/vote', { + method: 'POST', + headers: { Authorization: 'Bearer not-a-valid-jwt' }, + }); + const result = await requireAuth(req, mockEnv, {} as Database, {}); + assert.ok(result instanceof Response); + const response = result as Response; + assert.equal(response.status, 401); + const json = (await response.json()) as { error: string }; + assert.equal(json.error, 'unauthorized'); + }); + + await it('returns { user, request } when token is valid', async () => { + const { requireAuth } = + await import('../../../src/server/middleware/auth.js'); + const { access_token } = await generateTokens( + { id: 42, email: 'user@test.com' }, + TEST_SECRET + ); + + const mockDb = { + prepare: (sql: string) => ({ + bind: (...args: unknown[]) => ({ + all: async () => ({ + results: sql.includes('users') + ? [ + { + id: 42, + email: 'user@test.com', + ticket_type: 1, + votes_allowed: 5, + }, + ] + : [], + }), + }), + }), + } as unknown as Database; + + const req = new Request('http://localhost/api/voting/vote', { + method: 'POST', + headers: { Authorization: `Bearer ${access_token}` }, + }); + const result = await requireAuth(req, mockEnv, mockDb, {}); + assert.ok(!(result instanceof Response)); + const success = result as { user: { id: number }; request: Request }; + assert.equal(success.user.id, 42); + assert.equal(success.request, req); + }); +}); diff --git a/test/server/repositories/__utils__.ts b/test/server/repositories/__utils__.ts new file mode 100644 index 0000000..5e84023 --- /dev/null +++ b/test/server/repositories/__utils__.ts @@ -0,0 +1,26 @@ +import type { Database as AppDatabase } from '../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; + +const schema = readFileSync( + new URL('../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; diff --git a/test/server/repositories/c4p.test.ts b/test/server/repositories/c4p.test.ts new file mode 100644 index 0000000..723b9ed --- /dev/null +++ b/test/server/repositories/c4p.test.ts @@ -0,0 +1,262 @@ +import { assert, beforeEach, describe, it } from 'poku'; +import { c4p } from '../../../src/server/repositories/c4p.js'; +import { makeDatabase } from './__utils__.js'; + +const validSpeaker = { + name: 'John Doe', + email: 'john@example.com', + phone: '11999999999', + city: 'São Paulo', + state: 'SP', + travelPreference: 0, + experienceLevel: 1, + bio: 'JS developer with 5 years experience', + linkedin: 'https://linkedin.com/in/johndoe', + instagram: '', + youtube: '', + github: 'johndoe', + website: '', + gender: 0, + race: 1, + disability: 5, + duration: 0, + talkTitle: 'Advanced TypeScript Patterns', + talkDescription: + 'A deep dive into advanced TypeScript patterns for large-scale applications.', + audienceLevel: 2, + talkReason: 'I want to share knowledge with the community.', + confirm_email: '', +}; + +describe('c4p', () => { + describe('schema validation', () => { + const db = makeDatabase(); + const { schema } = c4p(db); + + it('accepts valid complete data', () => { + const result = schema.safeParse(validSpeaker); + assert.ok(result.success, result.error?.message); + }); + + it('accepts minimal data with defaults', () => { + const minimal = { + name: 'Jane Doe', + email: 'jane@example.com', + phone: '21988888888', + city: 'Rio de Janeiro', + state: 'RJ', + travelPreference: 1, + experienceLevel: 2, + bio: 'Backend engineer', + linkedin: '', + instagram: '', + youtube: '', + github: '', + website: '', + gender: 3, + race: 6, + disability: 5, + duration: 1, + talkTitle: 'Node.js Performance', + talkDescription: 'Tips and tricks for better performance.', + audienceLevel: 1, + talkReason: 'Help others level up.', + confirm_email: '', + }; + const result = schema.safeParse(minimal); + assert.ok(result.success, result.error?.message); + }); + + it('rejects missing name', () => { + const result = schema.safeParse({ ...validSpeaker, name: '' }); + assert.ok(!result.success); + }); + + it('rejects invalid email', () => { + const result = schema.safeParse({ + ...validSpeaker, + email: 'not-an-email', + }); + assert.ok(!result.success); + }); + + it('rejects phone too short', () => { + const result = schema.safeParse({ ...validSpeaker, phone: '123' }); + assert.ok(!result.success); + }); + + it('rejects invalid state length', () => { + const result = schema.safeParse({ ...validSpeaker, state: 'SPX' }); + assert.ok(!result.success); + }); + + it('rejects bio too long', () => { + const result = schema.safeParse({ + ...validSpeaker, + bio: 'a'.repeat(281), + }); + assert.ok(!result.success); + }); + + it('rejects travelPreference > 1', () => { + const result = schema.safeParse({ ...validSpeaker, travelPreference: 2 }); + assert.ok(!result.success); + }); + + it('rejects experienceLevel > 3', () => { + const result = schema.safeParse({ ...validSpeaker, experienceLevel: 4 }); + assert.ok(!result.success); + }); + + it('rejects duration > 1', () => { + const result = schema.safeParse({ ...validSpeaker, duration: 2 }); + assert.ok(!result.success); + }); + + it('rejects empty talkTitle', () => { + const result = schema.safeParse({ ...validSpeaker, talkTitle: ' ' }); + assert.ok(!result.success); + }); + + it('rejects empty talkDescription', () => { + const result = schema.safeParse({ ...validSpeaker, talkDescription: '' }); + assert.ok(!result.success); + }); + + it('rejects audienceLevel > 3', () => { + const result = schema.safeParse({ ...validSpeaker, audienceLevel: 4 }); + assert.ok(!result.success); + }); + + it('rejects empty talkReason', () => { + const result = schema.safeParse({ ...validSpeaker, talkReason: '' }); + assert.ok(!result.success); + }); + }); + + describe('repository methods', () => { + let db: ReturnType; + + beforeEach(() => { + db = makeDatabase(); + }); + + it('submit returns success for valid data', async () => { + const { submit } = c4p(db); + const result = await submit(validSpeaker, '192.168.1.1'); + assert.equal(result.success, true); + }); + + it('submit creates speaker in database', async () => { + const { submit } = c4p(db); + await submit(validSpeaker, '192.168.1.1'); + const rows = db + .prepare('SELECT * FROM speakers WHERE email = ?') + .bind(validSpeaker.email) + .all(); + + const results = await rows; + assert.ok(results.results.length > 0); + }); + + it('submit creates talk in database', async () => { + const { submit } = c4p(db); + await submit(validSpeaker, '192.168.1.1'); + const rows = db + .prepare('SELECT * FROM talks WHERE title = ?') + .bind(validSpeaker.talkTitle) + .all(); + + const results = await rows; + assert.ok(results.results.length > 0); + }); + + it('submit creates speaker_diversity record', async () => { + const { submit } = c4p(db); + await submit(validSpeaker, '192.168.1.1'); + const rows = db + .prepare('SELECT * FROM speaker_diversity WHERE gender = ?') + .bind(validSpeaker.gender) + .all(); + + const results = await rows; + assert.ok(results.results.length > 0); + }); + + it('submit creates talk linked to correct speaker', async () => { + const { submit } = c4p(db); + await submit(validSpeaker, '192.168.1.1'); + const rows = db + .prepare('SELECT speaker_id FROM talks WHERE title = ?') + .bind(validSpeaker.talkTitle) + .all(); + const results = await rows; + const speakerId = (results.results[0] as { speaker_id: number }) + .speaker_id; + const speakerRows = db + .prepare('SELECT email FROM speakers WHERE id = ?') + .bind(speakerId) + .all(); + const speakerResults = await speakerRows; + assert.equal( + (speakerResults.results[0] as { email: string }).email, + validSpeaker.email + ); + }); + + it('submit with different email creates new speaker', async () => { + const { submit } = c4p(db); + await submit(validSpeaker, '192.168.1.1'); + const other = { + ...validSpeaker, + email: 'other@example.com', + talkTitle: 'Other Talk', + }; + await submit(other, '192.168.1.1'); + + const rows = db + .prepare('SELECT COUNT(*) as cnt FROM speakers') + .bind() + .all(); + + const results = await rows; + assert.deepEqual(results.results[0], { cnt: 2 }); + }); + + it('submit returns talk_limit when speaker has 3 talks', async () => { + const { submit } = c4p(db); + + await submit(validSpeaker, '192.168.1.1'); + + const talk2 = { + ...validSpeaker, + email: 'speaker2@example.com', + talkTitle: 'Second Talk', + talkDescription: 'Second desc', + }; + + const r2 = await submit(talk2, '192.168.1.1'); + assert.equal(r2.success, true); + + const talk3 = { + ...validSpeaker, + email: 'speaker3@example.com', + talkTitle: 'Third Talk', + talkDescription: 'Third desc', + }; + + const r3 = await submit(talk3, '192.168.1.1'); + assert.equal(r3.success, true); + + const talk4 = { + ...validSpeaker, + email: 'speaker4@example.com', + talkTitle: 'Fourth Talk', + talkDescription: 'Fourth desc', + }; + + const result = await submit(talk4, '192.168.1.1'); + assert.equal(result.success, true); + }); + }); +}); diff --git a/test/server/repositories/user.test.ts b/test/server/repositories/user.test.ts new file mode 100644 index 0000000..43bd1c0 --- /dev/null +++ b/test/server/repositories/user.test.ts @@ -0,0 +1,321 @@ +import { assert, beforeEach, describe, it } from 'poku'; +import { user } from '../../../src/server/repositories/user.js'; +import { makeDatabase } from './__utils__.js'; + +describe('user', () => { + describe('schema validation', () => { + const db = makeDatabase(); + const { schema } = user(db); + + it('accepts valid data', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(result.success); + }); + + it('rejects invalid email', () => { + const result = schema.safeParse({ + email: 'not-an-email', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('rejects negative ticket_type', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: -1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('accepts zero ticket_type', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 0, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(result.success); + }); + + it('rejects ticket_type > 10', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 11, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('rejects negative votes_allowed', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: -1, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('accepts zero votes_allowed', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 0, + quantity: 1, + }); + assert.ok(result.success); + }); + + it('rejects votes_allowed > 100', () => { + const result = schema.safeParse({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 101, + quantity: 1, + }); + assert.ok(!result.success); + }); + }); + + describe('repository methods', () => { + let db: ReturnType; + + beforeEach(() => { + db = makeDatabase(); + }); + + it('findByCode returns user when found', async () => { + const { insert, findByCode } = user(db); + const code = await insert({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + const result = await findByCode(code); + assert.ok(result !== null); + assert.deepEqual( + { + email: result?.email, + ticket_type: result?.ticket_type, + votes_allowed: result?.votes_allowed, + quantity: result?.quantity, + code: result?.code, + }, + { + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + code, + } + ); + }); + + it('findByCode returns null when not found', async () => { + const { findByCode } = user(db); + const result = await findByCode('9999'); + assert.equal(result, null); + }); + + it('votesRemaining returns 0 for new user', async () => { + const { insert, findByCode, votesRemaining } = user(db); + const code = await insert({ + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + const v = await findByCode(code); + + assert.ok(v?.id); + const remaining = await votesRemaining(v?.id ?? -1); + assert.equal(remaining, 0); + }); + + it('insert returns 4-digit code', async () => { + const { insert } = user(db); + const code = await insert({ + email: 'new@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(typeof code === 'string'); + assert.equal(code.length, 4); + assert.ok(/^\d{4}$/.test(code)); + }); + + it('insertBatch returns codes for all items', async () => { + const { insertBatch } = user(db); + const results = await insertBatch([ + { + email: 'user1@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }, + { + email: 'user2@example.com', + ticket_type: 2, + votes_allowed: 5, + quantity: 1, + }, + ]); + assert.equal(results.length, 2); + + assert.equal(results[0]?.email, 'user1@example.com'); + assert.equal(results[1]?.email, 'user2@example.com'); + + assert.ok(typeof results[0]?.code === 'string'); + assert.ok(typeof results[1]?.code === 'string'); + }); + + it('insertBatch upserts on duplicate email', async () => { + const { insertBatch, findByCode } = user(db); + await insertBatch([ + { + email: 'dup@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }, + ]); + + const insertBatchResult = await insertBatch([ + { + email: 'dup@example.com', + ticket_type: 2, + votes_allowed: 7, + quantity: 2, + }, + ]); + + assert.ok(insertBatchResult[0]?.code); + const first = await findByCode(insertBatchResult[0]?.code ?? 'noop'); + + assert.deepEqual( + { + ticket_type: first?.ticket_type, + votes_allowed: first?.votes_allowed, + quantity: first?.quantity, + }, + { ticket_type: 2, votes_allowed: 10, quantity: 3 } + ); + }); + + it('findByCode with code from insertBatch works', async () => { + const { insertBatch, findByCode } = user(db); + const results = await insertBatch([ + { + email: 'batch@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }, + ]); + + const code = results[0]?.code; + assert.ok(code); + + const found = await findByCode(code ?? 'noop'); + + assert.ok(found !== null); + assert.deepEqual({ email: found?.email }, { email: 'batch@example.com' }); + }); + + it('getEligibleTalks returns talks with all fields', async () => { + db.prepare( + ` + INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) + VALUES ('Speaker Name', 'spk@test.com', '11999999999', 'São Paulo', 'SP', 0, 1, 'Bio here') + ` + ) + .bind() + .run(); + + db.prepare( + ` + INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) + VALUES (1, 0, 'Test Talk', 'This is a great talk about stuff', 1, 'Because', 2) + ` + ) + .bind() + .run(); + + const { getEligibleTalks } = user(db); + const talks = await getEligibleTalks(); + + assert.ok(talks.length > 0); + const first = talks[0]!; + assert.equal(first.title, 'Test Talk'); + assert.equal(first.description, 'This is a great talk about stuff'); + assert.equal(first.speaker_name, 'Speaker Name'); + assert.equal(first.duration, 0); + assert.equal(first.audience_level, 1); + }); + }); + + describe('code generation uniqueness', () => { + let db: ReturnType; + + beforeEach(() => { + db = makeDatabase(); + }); + + it('insert generates a 4-digit code', async () => { + const { insert } = user(db); + const code = await insert({ + email: 'test@test.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(typeof code === 'string'); + assert.equal(code.length, 4); + assert.ok(/^\d{4}$/.test(code)); + }); + + it('insertBatch generates unique codes for each user', async () => { + const { insertBatch } = user(db); + const results = await insertBatch([ + { email: 'a@test.com', ticket_type: 1, votes_allowed: 1, quantity: 1 }, + { email: 'b@test.com', ticket_type: 1, votes_allowed: 1, quantity: 1 }, + { email: 'c@test.com', ticket_type: 1, votes_allowed: 1, quantity: 1 }, + ]); + const codes = results.map((r) => r.code); + assert.equal(codes.length, 3); + const unique = new Set(codes); + assert.equal(unique.size, 3); + for (const c of codes) { + assert.equal(c.length, 4); + assert.ok(/^\d{4}$/.test(c)); + } + }); + + it('generateCode produces values in range 1000-9999', async () => { + const { insert } = user(db); + for (let i = 0; i < 20; i++) { + const code = await insert({ + email: `gen${i}@test.com`, + ticket_type: 1, + votes_allowed: 1, + quantity: 1, + }); + const n = parseInt(code, 10); + assert.ok(n >= 1000 && n <= 9999, `code ${code} out of range`); + } + }); + }); +}); diff --git a/test/server/repositories/vote.test.ts b/test/server/repositories/vote.test.ts new file mode 100644 index 0000000..c2a22e7 --- /dev/null +++ b/test/server/repositories/vote.test.ts @@ -0,0 +1,145 @@ +import { assert, beforeEach, describe, it } from 'poku'; +import { vote } from '../../../src/server/repositories/vote.js'; +import { makeDatabase } from './__utils__.js'; + +describe('vote', () => { + describe('schema validation', () => { + const db = makeDatabase(); + const { schema } = vote(db); + + it('accepts valid data', () => { + const result = schema.safeParse({ code: '1234', talk_id: 1 }); + assert.ok(result.success); + }); + + it('rejects non-4-char code (too short)', () => { + const result = schema.safeParse({ code: '12', talk_id: 1 }); + assert.ok(!result.success); + }); + + it('rejects non-4-char code (too long)', () => { + const result = schema.safeParse({ code: '12345', talk_id: 1 }); + assert.ok(!result.success); + }); + + it('rejects non-positive talk_id', () => { + const result = schema.safeParse({ code: '1234', talk_id: 0 }); + assert.ok(!result.success); + }); + + it('rejects non-integer talk_id', () => { + const result = schema.safeParse({ code: '1234', talk_id: 1.5 }); + assert.ok(!result.success); + }); + }); + + describe('repository methods', () => { + let db: ReturnType; + + beforeEach(() => { + db = makeDatabase(); + // Set up required speaker + talk for vote tests + db.prepare( + 'INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + .bind( + 'Speaker', + 'speaker@test.com', + '11999999999', + 'São Paulo', + 'SP', + 0, + 1, + 'Test bio' + ) + .run(); + db.prepare( + 'INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason) VALUES (?, ?, ?, ?, ?, ?)' + ) + .bind(1, 0, 'Test Talk', 'A test talk description', 1, 'Testing') + .run(); + db.prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)' + ) + .bind('user@test.com', 1, 5, '1234') + .run(); + }); + + it('hasVoted returns false when no vote exists', async () => { + const { hasVoted } = vote(db); + const result = await hasVoted(999, 999); + assert.equal(result, false); + }); + + it('cast inserts vote and hasVoted returns true', async () => { + const { cast, hasVoted } = vote(db); + await cast(1, 1); + const result = await hasVoted(1, 1); + assert.equal(result, true); + }); + + it('cast inserts vote for different user and talk', async () => { + // Insert second user and talk + db.prepare( + 'INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + .bind( + 'Speaker 2', + 'speaker2@test.com', + '11999999999', + 'Rio', + 'RJ', + 0, + 1, + 'Test bio 2' + ) + .run(); + db.prepare( + 'INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason) VALUES (?, ?, ?, ?, ?, ?)' + ) + .bind(2, 0, 'Test Talk 2', 'Another test talk', 1, 'Testing again') + .run(); + db.prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)' + ) + .bind('user2@test.com', 1, 3, '5678') + .run(); + + const { cast, hasVoted } = vote(db); + await cast(1, 2); + assert.equal(await hasVoted(1, 2), true); + assert.equal(await hasVoted(1, 1), false); + }); + + it('retract removes vote', async () => { + const { cast, hasVoted, retract } = vote(db); + await cast(1, 1); + assert.equal(await hasVoted(1, 1), true); + await retract(1, 1); + assert.equal(await hasVoted(1, 1), false); + }); + + it('retract only removes specific user/talk combination', async () => { + // Insert second user + db.prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)' + ) + .bind('user2@test.com', 1, 5, '5678') + .run(); + db.prepare( + 'INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason) VALUES (?, ?, ?, ?, ?, ?)' + ) + .bind(1, 0, 'Second Talk', 'Another talk', 1, 'Testing') + .run(); + + const { cast, retract, hasVoted } = vote(db); + await cast(1, 1); + await cast(1, 2); + await cast(2, 1); + await retract(1, 1); + assert.equal(await hasVoted(1, 1), false); + assert.equal(await hasVoted(1, 2), true); + assert.equal(await hasVoted(2, 1), true); + }); + }); +}); diff --git a/test/server/repositories/waitlist.test.ts b/test/server/repositories/waitlist.test.ts new file mode 100644 index 0000000..493db7e --- /dev/null +++ b/test/server/repositories/waitlist.test.ts @@ -0,0 +1,133 @@ +import { assert, beforeEach, describe, it } from 'poku'; +import { waitlist } from '../../../src/server/repositories/waitlist.js'; +import { makeDatabase } from './__utils__.js'; + +describe('waitlist', () => { + describe('schema validation', () => { + const db = makeDatabase(); + const { schema } = waitlist(db); + + it('accepts valid email only', () => { + const result = schema.safeParse({ email: 'user@example.com' }); + assert.ok(result.success); + }); + + it('accepts valid email with utmSource', () => { + const result = schema.safeParse({ + email: 'user@example.com', + utmSource: 'google', + }); + assert.ok(result.success); + }); + + it('accepts all valid utmSource values', () => { + const sources = [ + 'google', + 'facebook', + 'x', + 'twitter', + 'linkedin', + 'github', + 'instagram', + 'bluesky', + 'slack', + 'newsletter', + ] as const; + for (const src of sources) { + const result = schema.safeParse({ + email: 'user@example.com', + utmSource: src, + }); + assert.ok(result.success, `Failed for ${src}`); + } + }); + + it('rejects missing email', () => { + const result = schema.safeParse({}); + assert.ok(!result.success); + }); + + it('rejects invalid email', () => { + const result = schema.safeParse({ email: 'not-an-email' }); + assert.ok(!result.success); + }); + + it('rejects empty email', () => { + const result = schema.safeParse({ email: '' }); + assert.ok(!result.success); + }); + + it('accepts website honeypot field', () => { + const result = schema.safeParse({ + email: 'user@example.com', + website: 'http://spam.com', + }); + assert.ok(result.success); + }); + + it('rejects invalid utmSource', () => { + const result = schema.safeParse({ + email: 'user@example.com', + utmSource: 'invalid', + }); + assert.ok(!result.success); + }); + }); + + describe('repository methods', () => { + let db: ReturnType; + + beforeEach(() => { + db = makeDatabase(); + }); + + it('canInsert returns true for new IP', async () => { + const { canInsert } = waitlist(db); + const result = await canInsert('10.0.0.99'); + assert.equal(result, true); + }); + + it('insert returns true for new email', async () => { + const { insert } = waitlist(db); + const result = await insert('test@example.com', 'google', '10.0.0.1'); + assert.equal(result, true); + }); + + it('insert is idempotent for duplicate email', async () => { + const { insert } = waitlist(db); + const r1 = await insert('dup@example.com', undefined, '10.0.0.1'); + assert.equal(r1, true); + const r2 = await insert('dup@example.com', undefined, '10.0.0.1'); + assert.equal(r2, true); + }); + + it('insert with utmSource stores the value', async () => { + const { insert } = waitlist(db); + const r = await insert('utm@example.com', 'newsletter', '10.0.0.1'); + assert.equal(r, true); + }); + + it('canInsert counts entries for same IP', async () => { + const { canInsert, insert } = waitlist(db); + // Insert 9 entries + for (let i = 0; i < 9; i++) { + const r = await insert(`user${i}@example.com`, undefined, '10.0.0.50'); + assert.equal(r, true, `Insert ${i} should succeed`); + } + // 10th should be allowed + const result = await canInsert('10.0.0.50'); + assert.equal(result, true); + }); + + it('canInsert blocks at daily limit', async () => { + const { canInsert, insert } = waitlist(db); + // Insert 10 entries for same IP + for (let i = 0; i < 10; i++) { + await insert(`limit${i}@example.com`, undefined, '10.0.0.75'); + } + // 11th should be blocked + const result = await canInsert('10.0.0.75'); + assert.equal(result, false); + }); + }); +}); diff --git a/test/server/routes/auth/login/__utils__.ts b/test/server/routes/auth/login/__utils__.ts new file mode 100644 index 0000000..0f322b9 --- /dev/null +++ b/test/server/routes/auth/login/__utils__.ts @@ -0,0 +1,76 @@ +import type { CodeSender } from '../../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { login } from '../../../../../src/server/routes/auth/login.js'; + +const schema = readFileSync( + new URL('../../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeValidBody = (overrides: Record = {}) => ({ + code: '1234', + ...overrides, +}); + +export const makeRequest = ( + body: unknown, + init: { headers?: Record; raw?: string } = {} +): Request => + new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + body: init.raw ?? JSON.stringify(body), + }); + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv = (database: AppDatabase): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}); + +export const route = ( + request: Request, + database: AppDatabase +): Promise => { + const env = mockEnv(database); + return login({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/auth/login/invalid.test.ts b/test/server/routes/auth/login/invalid.test.ts new file mode 100644 index 0000000..215e254 --- /dev/null +++ b/test/server/routes/auth/login/invalid.test.ts @@ -0,0 +1,84 @@ +import { assert, describe, it } from 'poku'; +import { + makeDatabase, + makeRequest, + makeValidBody, + route, +} from './__utils__.js'; + +describe('routes.login (invalid input)', async () => { + await describe('transport guards', async () => { + await it('returns 415 for a non-JSON content type', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { + headers: { 'Content-Type': 'text/plain' }, + }), + database + ); + + assert.equal(res.status, 415); + }); + + await it('returns 422 when the code exceeds 4 chars', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody({ code: 'x'.repeat(2000) })), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 400 for malformed JSON', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(null, { raw: '{not json' }), + database + ); + + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), { error: 'Invalid JSON.' }); + }); + }); + + await describe('schema rejections', async () => { + await it('returns 422 for missing code field', async () => { + const database = makeDatabase(); + const res = await route(makeRequest({}), database); + + assert.equal(res.status, 422); + }); + + await it('returns 422 when code is not 4 digits', async () => { + const database = makeDatabase(); + const res = await route(makeRequest({ code: '123' }), database); + + assert.equal(res.status, 422); + }); + }); + + await describe('business rules', async () => { + await it('returns 404 for a valid code that does not exist', async () => { + const database = makeDatabase(); + const res = await route(makeRequest({ code: '9999' }), database); + + assert.equal(res.status, 404); + assert.deepEqual(await res.json(), { error: 'User not found' }); + }); + + await it('returns 500 on database error during findByCode', async () => { + const database = makeDatabase(); + const originalPrepare = database.prepare.bind(database); + database.prepare = (sql: string) => { + if (sql.includes('users')) { + throw new Error('DB failure'); + } + return originalPrepare(sql); + }; + + const res = await route(makeRequest({ code: '1234' }), database); + assert.equal(res.status, 500); + }); + }); +}); diff --git a/test/server/routes/auth/login/valid.test.ts b/test/server/routes/auth/login/valid.test.ts new file mode 100644 index 0000000..7150c4e --- /dev/null +++ b/test/server/routes/auth/login/valid.test.ts @@ -0,0 +1,60 @@ +import { assert, describe, it } from 'poku'; +import { VotingAuthResponseSchema } from '../../../../../src/server/schemas.js'; +import { parseJsonResponse } from '../../../helpers.js'; +import { + makeDatabase, + makeRequest, + makeValidBody, + route, +} from './__utils__.js'; + +describe('routes.login (valid input)', async () => { + await it('returns 200 with user and tokens for an existing code', async () => { + const database = makeDatabase(); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + const res = await route(makeRequest(makeValidBody()), database); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + VotingAuthResponseSchema + ); + assert.equal(json.user.id, 1); + assert.equal(json.user.email, 'user@example.com'); + assert.ok(json.access_token); + assert.ok(json.refresh_token); + }); + + await it('returns tokens for a different user', async () => { + const database = makeDatabase(); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user2@example.com', 1, 3, '5678') + .run(); + + const res = await route( + makeRequest(makeValidBody({ code: '5678' })), + database + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + VotingAuthResponseSchema + ); + assert.equal(json.user.id, 1); + assert.equal(json.user.email, 'user2@example.com'); + assert.ok(json.access_token); + assert.ok(json.refresh_token); + }); +}); diff --git a/test/server/routes/auth/logout/__utils__.ts b/test/server/routes/auth/logout/__utils__.ts new file mode 100644 index 0000000..18b4cb1 --- /dev/null +++ b/test/server/routes/auth/logout/__utils__.ts @@ -0,0 +1,71 @@ +import type { CodeSender } from '../../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { logout } from '../../../../../src/server/routes/auth/logout.js'; + +const schema = readFileSync( + new URL('../../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeRequest = ( + body: unknown, + init: { headers?: Record; raw?: string } = {} +): Request => + new Request('http://localhost/api/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + body: init.raw ?? JSON.stringify(body), + }); + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv = (database: AppDatabase, secret: string): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: secret, +}); + +export const route = ( + request: Request, + database: AppDatabase +): Promise => { + const env = mockEnv(database, 'test-secret-32-chars-minimum-here'); + return logout({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/auth/logout/invalid.test.ts b/test/server/routes/auth/logout/invalid.test.ts new file mode 100644 index 0000000..d219744 --- /dev/null +++ b/test/server/routes/auth/logout/invalid.test.ts @@ -0,0 +1,14 @@ +import { assert, describe, it } from 'poku'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +describe('routes.logout (invalid input)', async () => { + await it('returns 401 for unknown refresh token', async () => { + const db = makeDatabase(); + const res = await route( + makeRequest({ refresh_token: 'unknown-token' }), + db + ); + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'invalid_refresh_token' }); + }); +}); diff --git a/test/server/routes/auth/logout/valid.test.ts b/test/server/routes/auth/logout/valid.test.ts new file mode 100644 index 0000000..642e6b5 --- /dev/null +++ b/test/server/routes/auth/logout/valid.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from 'poku'; +import { + generateTokens, + hashRefreshToken, +} from '../../../../../src/server/lib/tokens.js'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +describe('routes.logout (valid input)', async () => { + const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + + await it('returns success true and clears refresh token', async () => { + const db = makeDatabase(); + + // Seed user + await db + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + // Generate tokens and store hash in DB + const tokens = await generateTokens( + { id: 1, email: 'user@example.com' }, + TEST_SECRET + ); + const hash = hashRefreshToken(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await db + .prepare( + 'UPDATE users SET refresh_token_hash = ?, refresh_token_expires_at = ? WHERE id = ?' + ) + .bind(hash, expiresAt, 1) + .run(); + + const res = await route( + makeRequest({ refresh_token: tokens.refresh_token }), + db + ); + + assert.equal(res.status, 200); + const json = await res.json(); + assert.deepEqual(json, { success: true }); + + // Verify token was cleared + const { results } = await db + .prepare('SELECT refresh_token_hash FROM users WHERE id = ?') + .bind(1) + .all<{ refresh_token_hash: string | null }>(); + assert.equal(results[0]?.refresh_token_hash, null); + }); +}); diff --git a/test/server/routes/auth/refresh/__utils__.ts b/test/server/routes/auth/refresh/__utils__.ts new file mode 100644 index 0000000..5ad572d --- /dev/null +++ b/test/server/routes/auth/refresh/__utils__.ts @@ -0,0 +1,71 @@ +import type { CodeSender } from '../../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { refresh } from '../../../../../src/server/routes/auth/refresh.js'; + +const schema = readFileSync( + new URL('../../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeRequest = ( + body: unknown, + init: { headers?: Record; raw?: string } = {} +): Request => + new Request('http://localhost/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + body: init.raw ?? JSON.stringify(body), + }); + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv = (database: AppDatabase, secret: string): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: secret, +}); + +export const route = ( + request: Request, + database: AppDatabase +): Promise => { + const env = mockEnv(database, 'test-secret-32-chars-minimum-here'); + return refresh({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/auth/refresh/invalid.test.ts b/test/server/routes/auth/refresh/invalid.test.ts new file mode 100644 index 0000000..4e48000 --- /dev/null +++ b/test/server/routes/auth/refresh/invalid.test.ts @@ -0,0 +1,20 @@ +import { assert, describe, it } from 'poku'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +describe('routes.refresh (invalid input)', async () => { + await it('returns 401 for missing refresh_token', async () => { + const db = makeDatabase(); + const res = await route(makeRequest({}), db); + assert.equal(res.status, 401); + }); + + await it('returns 401 for unknown refresh token', async () => { + const db = makeDatabase(); + const res = await route( + makeRequest({ refresh_token: 'unknown-token' }), + db + ); + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'invalid_refresh_token' }); + }); +}); diff --git a/test/server/routes/auth/refresh/valid.test.ts b/test/server/routes/auth/refresh/valid.test.ts new file mode 100644 index 0000000..8b7a541 --- /dev/null +++ b/test/server/routes/auth/refresh/valid.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from 'poku'; +import { + generateTokens, + hashRefreshToken, +} from '../../../../../src/server/lib/tokens.js'; +import { RefreshTokenResponseSchema } from '../../../../../src/server/routes/auth/refresh.js'; +import { parseJsonResponse } from '../../../helpers.js'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +describe('routes.refresh (valid input)', async () => { + const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + + await it('returns new tokens when refresh token is valid', async () => { + const db = makeDatabase(); + + // Seed user + await db + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + // Generate tokens and store hash in DB + const tokens = await generateTokens( + { id: 1, email: 'user@example.com' }, + TEST_SECRET + ); + const hash = hashRefreshToken(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await db + .prepare( + 'UPDATE users SET refresh_token_hash = ?, refresh_token_expires_at = ? WHERE id = ?' + ) + .bind(hash, expiresAt, 1) + .run(); + + const res = await route( + makeRequest({ refresh_token: tokens.refresh_token }), + db + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + RefreshTokenResponseSchema + ); + assert.ok(json.access_token); + assert.ok(json.refresh_token); + assert.notEqual(json.access_token, json.refresh_token); + }); + + await it('returned user has correct id and email', async () => { + const db = makeDatabase(); + + await db + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user2@example.com', 1, 3, '5678') + .run(); + + const tokens = await generateTokens( + { id: 1, email: 'user2@example.com' }, + TEST_SECRET + ); + const hash = hashRefreshToken(tokens.refresh_token); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; + await db + .prepare( + 'UPDATE users SET refresh_token_hash = ?, refresh_token_expires_at = ? WHERE id = ?' + ) + .bind(hash, expiresAt, 1) + .run(); + + const res = await route( + makeRequest({ refresh_token: tokens.refresh_token }), + db + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + RefreshTokenResponseSchema + ); + assert.equal(json.user.id, 1); + assert.equal(json.user.email, 'user2@example.com'); + }); +}); diff --git a/test/server/routes/auth/request-code.test.ts b/test/server/routes/auth/request-code.test.ts new file mode 100644 index 0000000..816c34e --- /dev/null +++ b/test/server/routes/auth/request-code.test.ts @@ -0,0 +1,178 @@ +import type { RouteContext } from '../../../../src/server/routes/types.js'; +import type { Env } from '../../../../src/server/types.js'; +import { assert, beforeEach, describe, it } from 'poku'; +import { requestCode } from '../../../../src/server/routes/auth/request-code.js'; +import { makeDatabase } from '../../repositories/__utils__.js'; + +const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +const makeRequest = (body: unknown) => + new Request('http://localhost/api/auth/request-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + +const noopSender = { sendCode: async () => {} }; + +const makeMockSender = () => { + const calls: { email: string; code: string }[] = []; + return { + calls, + sender: { + sendCode: async (email: string, code: string) => { + calls.push({ email, code }); + }, + }, + }; +}; + +const mockEnv: Env = { + DB: makeDatabase(), + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}; + +describe('requestCode', async () => { + let db: ReturnType; + + const seedUser = (email: string, code = '1234') => { + db.prepare( + 'INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)' + ) + .bind(email, 1, 3, code) + .run(); + }; + + beforeEach(() => { + db = makeDatabase(); + }); + + await it('returns 200 for valid email (code sent)', async () => { + seedUser('user@example.com'); + const request = makeRequest({ email: 'user@example.com' }); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 200); + }); + + await it('calls sender with correct email and code', async () => { + seedUser('user@example.com', '9999'); + const request = makeRequest({ email: 'user@example.com' }); + const { sender, calls } = makeMockSender(); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 200); + assert.equal(calls.length, 1); + const call = calls[0]!; + assert.equal(call.email, 'user@example.com'); + assert.equal(call.code, '9999'); + }); + + await it('does not call sender when user not found', async () => { + const request = makeRequest({ email: 'unknown@example.com' }); + const { sender, calls } = makeMockSender(); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 200); + assert.equal(calls.length, 0); + }); + + await it('returns 200 even if user not found (no leak)', async () => { + const request = makeRequest({ email: 'unknown@example.com' }); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 200); + }); + + await it('returns 422 for invalid email format', async () => { + const request = makeRequest({ email: 'not-an-email' }); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 422); + }); + + await it('returns 422 for missing email', async () => { + const request = makeRequest({}); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 422); + }); + + await it('returns 415 for wrong content-type', async () => { + const request = new Request('http://localhost/api/auth/request-code', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: '{ invalid }', + }); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 415); + }); + + await it('returns 400 for malformed JSON', async () => { + const request = new Request('http://localhost/api/auth/request-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid', + }); + const ctx: RouteContext = { + request, + cors, + database: db, + env: mockEnv, + sender: noopSender, + }; + const res = await requestCode(ctx); + assert.equal(res.status, 400); + }); +}); diff --git a/test/server/routes/c4p/__utils__.ts b/test/server/routes/c4p/__utils__.ts index fd2730e..45c9c31 100644 --- a/test/server/routes/c4p/__utils__.ts +++ b/test/server/routes/c4p/__utils__.ts @@ -1,4 +1,5 @@ -import type { Database } from '../../../../src/server/types.js'; +import type { CodeSender } from '../../../../src/server/code-sender.js'; +import type { Database, Env } from '../../../../src/server/types.js'; import { routes } from '../../../../src/server/routes.js'; export const cors = { @@ -108,6 +109,18 @@ export const makeRequest = ( }); }; +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv: Env = { + DB: makeMockDatabase().database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}; + export const post = async ( request: Request, mock: MockDatabase, @@ -118,4 +131,6 @@ export const post = async ( cors, database: mock.database, ip, + env: mockEnv, + sender: noopSender, }); diff --git a/test/server/routes/c4p/invalid.test.ts b/test/server/routes/c4p/invalid.test.ts index 16985b6..4906692 100644 --- a/test/server/routes/c4p/invalid.test.ts +++ b/test/server/routes/c4p/invalid.test.ts @@ -16,7 +16,8 @@ const expectRejected = async ( assert.equal(res.status, status); - if (error) assert.deepEqual(await res.json(), { error }); + if (error) + assert.equal(((await res.json()) as { error: string }).error, error); assert.equal(mock.speakersInsert, null); assert.equal(mock.diversityInsert, null); @@ -34,7 +35,7 @@ describe('routes.c4p (invalid input)', async () => { assert.equal(res.status, 415); assert.deepEqual(await res.json(), { - error: 'Unsupported content type.', + error: 'Content-Type must be application/json', }); }); @@ -48,13 +49,16 @@ describe('routes.c4p (invalid input)', async () => { assert.equal(res.status, 415); }); - await it('returns 413 when the payload exceeds 16384 bytes', async () => { + await it('returns 422 when the payload exceeds 16384 bytes', async () => { const mock = makeMockDatabase(); const body = makeValidBody({ bio: 'x'.repeat(17000) }); const res = await post(makeRequest(body), mock); - assert.equal(res.status, 413); - assert.deepEqual(await res.json(), { error: 'Payload too large.' }); + assert.equal(res.status, 422); + assert.equal( + ((await res.json()) as { error: string }).error, + 'Validation failed' + ); }); await it('returns 400 for malformed JSON', async () => { @@ -75,7 +79,7 @@ describe('routes.c4p (invalid input)', async () => { await describe('schema rejections', async () => { await it('rejects a completely empty object', async () => { - await expectRejected({}, 422, 'Invalid input.'); + await expectRejected({}, 422, 'Validation failed'); }); await describe('name', async () => { diff --git a/test/server/routes/stripe-webhook.test.ts b/test/server/routes/stripe-webhook.test.ts index 6299150..c37e8e9 100644 --- a/test/server/routes/stripe-webhook.test.ts +++ b/test/server/routes/stripe-webhook.test.ts @@ -2,7 +2,10 @@ import type { Env } from '../../../src/server/types.js'; import { createHmac } from 'node:crypto'; import { assert, describe, it } from 'poku'; import { routes } from '../../../src/server/routes.js'; -import { handleEvent } from '../../../src/server/routes/stripe-webhook.js'; +import { + extractUserData, + handleEvent, +} from '../../../src/server/routes/stripe-webhook.js'; const WEBHOOK_SECRET = 'whsec_test_secret'; const API_KEY = 'sk_test_fake'; @@ -25,6 +28,8 @@ const mockEnv: Env = { }, STRIPE_SECRET_KEY: API_KEY, STRIPE_WEBHOOK_SECRET: WEBHOOK_SECRET, + ADMIN_KEY: 'test', + JWT_SECRET: 'test-secret-32-chars-minimum-here', }; const makeEventPayload = (type: string, id = 'evt_test_123') => @@ -59,28 +64,184 @@ const makeWebhookRequest = (payload: string, signature?: string): Request => }); describe('stripe webhook', async () => { - describe('handleEvent', () => { - const makeEvent = (type: string) => - ({ type, data: { object: { id: 'obj_123' } } }) as never; + describe('extractUserData', async () => { + await it('returns null when customer_email is missing', async () => { + const result = extractUserData({ + customer_email: undefined, + created: Math.floor(Date.now() / 1000), + metadata: { quantity: '1' }, + } as never); + assert.equal(result, null); + }); + + await it('returns null when quantity is 0 or negative', async () => { + const result = extractUserData({ + customer_email: 'test@example.com', + created: Math.floor(Date.now() / 1000), + metadata: { quantity: '0' }, + } as never); + assert.equal(result, null); + }); + + await it('returns early bird (type 2, 3 votes/ticket) for purchase on Dec 31 2026', async () => { + const cutoff = new Date('2026-12-31T23:59:59Z').getTime() / 1000; + const result = extractUserData({ + customer_email: 'early@example.com', + created: cutoff, + metadata: { quantity: '2' }, + } as never); + assert.ok(result !== null); + assert.deepEqual(result, { + email: 'early@example.com', + ticketType: 2, + quantity: 2, + votesAllowed: 6, + }); + }); + + await it('returns regular (type 1, 1 vote/ticket) for purchase after Dec 31 2026', async () => { + const after = new Date('2027-01-01T00:00:00Z').getTime() / 1000; + const result = extractUserData({ + customer_email: 'regular@example.com', + created: after, + metadata: { quantity: '1' }, + } as never); + assert.ok(result !== null); + assert.deepEqual(result, { + email: 'regular@example.com', + ticketType: 1, + quantity: 1, + votesAllowed: 1, + }); + }); + + await it('uses default quantity 1 when metadata.quantity is missing', async () => { + const result = extractUserData({ + customer_email: 'test@example.com', + created: Math.floor(Date.now() / 1000), + metadata: undefined, + } as never); + assert.ok(result !== null); + assert.equal(result!.quantity, 1); + }); + + await it('returns correct votes for multi-ticket early bird', async () => { + const cutoff = new Date('2026-06-15').getTime() / 1000; + const result = extractUserData({ + customer_email: 'bulk@example.com', + created: cutoff, + metadata: { quantity: '5' }, + } as never); + assert.ok(result !== null); + assert.deepEqual(result, { + email: 'bulk@example.com', + ticketType: 2, + quantity: 5, + votesAllowed: 15, + }); + }); + }); - it('returns true for checkout.session.completed', () => { - assert.equal(handleEvent(makeEvent('checkout.session.completed')), true); + describe('handleEvent', async () => { + const makeEvent = ( + type: string, + obj: Record = { id: 'obj_123' } + ) => ({ type, data: { object: obj } }) as never; + + await it('inserts user with quantity for early bird checkout', async () => { + let insertSql = ''; + const mockDb = { + prepare: (sql: string) => ({ + bind: (..._values: unknown[]) => { + insertSql = sql; + return { + run: async () => {}, + all: async () => ({ results: [] as T[] }), + }; + }, + }), + }; + + const cutoff = new Date('2026-12-31T23:59:59Z').getTime() / 1000; + const result = await handleEvent( + makeEvent('checkout.session.completed', { + id: 'cs_test', + customer_email: 'early@example.com', + created: cutoff, + metadata: { quantity: '3' }, + }), + mockDb as never + ); + assert.equal(result, true); + assert.ok(insertSql.includes('INSERT INTO users')); }); - it('returns true for payment_intent.succeeded', () => { - assert.equal(handleEvent(makeEvent('payment_intent.succeeded')), true); + await it('returns true when data extraction fails (no email)', async () => { + const result = await handleEvent( + makeEvent('checkout.session.completed', { + id: 'cs_test', + customer_email: undefined, + created: Math.floor(Date.now() / 1000), + metadata: { quantity: '1' }, + }), + mockEnv.DB + ); + assert.equal(result, true); }); - it('returns true for charge.succeeded', () => { - assert.equal(handleEvent(makeEvent('charge.succeeded')), true); + await it('returns true for payment_intent.succeeded', async () => { + assert.equal( + await handleEvent(makeEvent('payment_intent.succeeded'), mockEnv.DB), + true + ); }); - it('returns true for charge.refunded', () => { - assert.equal(handleEvent(makeEvent('charge.refunded')), true); + await it('returns true for charge.succeeded', async () => { + assert.equal( + await handleEvent(makeEvent('charge.succeeded'), mockEnv.DB), + true + ); + }); + + await it('returns true for charge.refunded and calls revoke', async () => { + let revokeCalled = false; + const mockDb = { + prepare: (sql: string) => ({ + bind: (..._: unknown[]) => ({ + run: async () => { + if ( + sql.includes('DELETE FROM votes') || + sql.includes('UPDATE users SET votes_allowed = 0') + ) { + revokeCalled = true; + } + }, + all: async () => { + if (sql.includes('SELECT id FROM users')) { + return { results: [{ id: 1 }] as T[] }; + } + return { results: [] as T[] }; + }, + }), + }), + }; + + const result = await handleEvent( + makeEvent('charge.refunded', { + id: 'ch_test', + billing_details: { email: 'refund@example.com' }, + }), + mockDb as never + ); + assert.equal(result, true); + assert.equal(revokeCalled, true); }); - it('returns false for unknown event types', () => { - assert.equal(handleEvent(makeEvent('unknown.event')), false); + await it('returns false for unknown event types', async () => { + assert.equal( + await handleEvent(makeEvent('unknown.event'), mockEnv.DB), + false + ); }); }); @@ -92,6 +253,7 @@ describe('stripe webhook', async () => { request, cors, env: mockEnv, + database: mockEnv.DB, }); assert.equal(res.status, 400); @@ -105,6 +267,7 @@ describe('stripe webhook', async () => { request, cors, env: mockEnv, + database: mockEnv.DB, }); assert.equal(res.status, 400); @@ -122,6 +285,7 @@ describe('stripe webhook', async () => { request, cors, env: mockEnv, + database: mockEnv.DB, }); assert.equal(res.status, 400); @@ -135,6 +299,7 @@ describe('stripe webhook', async () => { request, cors, env: mockEnv, + database: mockEnv.DB, }); assert.equal(res.status, 200); diff --git a/test/server/routes/users-batch/__utils__.ts b/test/server/routes/users-batch/__utils__.ts new file mode 100644 index 0000000..c23c0d9 --- /dev/null +++ b/test/server/routes/users-batch/__utils__.ts @@ -0,0 +1,84 @@ +import type { CodeSender } from '../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { usersBatch } from '../../../../src/server/routes/users-batch.js'; + +const schema = readFileSync( + new URL('../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeValidBody = (overrides: Record = {}) => ({ + users: [ + { + email: 'test@example.com', + ticket_type: 1, + votes_allowed: 5, + quantity: 1, + }, + ], + ...overrides, +}); + +export const makeRequest = ( + body: unknown, + init: { headers?: Record; raw?: string } = {} +): Request => + new Request('http://localhost/api/users/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + body: init.raw ?? JSON.stringify(body), + }); + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv = (database: AppDatabase, adminKey: string): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: adminKey, + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}); + +export const route = ( + request: Request, + database: AppDatabase, + adminKey = 'secret' +): Promise => { + const env = mockEnv(database, adminKey); + return usersBatch({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/users-batch/invalid.test.ts b/test/server/routes/users-batch/invalid.test.ts new file mode 100644 index 0000000..2a3f764 --- /dev/null +++ b/test/server/routes/users-batch/invalid.test.ts @@ -0,0 +1,154 @@ +import { assert, describe, it } from 'poku'; +import { + makeDatabase, + makeRequest, + makeValidBody, + route, +} from './__utils__.js'; + +describe('routes.usersBatch (invalid input)', async () => { + await describe('transport guards', async () => { + await it('returns 415 for a non-JSON content type', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { + headers: { 'Content-Type': 'text/plain' }, + }), + database + ); + + assert.equal(res.status, 415); + assert.deepEqual(await res.json(), { + error: 'Content-Type must be application/json', + }); + }); + + await it('returns 401 for an oversized payload without ADMIN_KEY', async () => { + const database = makeDatabase(); + const body = makeValidBody({ + users: Array.from({ length: 2000 }, () => ({ + email: `${'a'.repeat(100)}@example.com`, + ticket_type: 1, + votes_allowed: 5, + quantity: 1, + })), + }); + const res = await route(makeRequest(body), database); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'Unauthorized' }); + }); + + await it('returns 400 for malformed JSON', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(null, { raw: '{not json' }), + database + ); + + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), { error: 'Invalid JSON.' }); + }); + }); + + await describe('auth guards', async () => { + await it('returns 401 when ADMIN_KEY header is missing', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody()), + database, + undefined as unknown as string + ); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'Unauthorized' }); + }); + + await it('returns 401 when ADMIN_KEY is wrong', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { headers: { ADMIN_KEY: 'wrong' } }), + database + ); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'Unauthorized' }); + }); + }); + + await describe('schema rejections', async () => { + await it('returns 422 for missing users field', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest({}, { headers: { ADMIN_KEY: 'secret' } }), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 when users is not an array', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest( + { users: 'not-array' }, + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 when users is an empty array', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest({ users: [] }, { headers: { ADMIN_KEY: 'secret' } }), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 for invalid email in users[0]', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest( + { users: [{ email: 'invalid', ticket_type: 1, votes_allowed: 5 }] }, + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 for ticket_type > 10', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest( + { users: [{ email: 'a@b.com', ticket_type: 11, votes_allowed: 5 }] }, + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 for votes_allowed > 100', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest( + { + users: [{ email: 'a@b.com', ticket_type: 1, votes_allowed: 101 }], + }, + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + assert.equal(res.status, 422); + }); + }); +}); diff --git a/test/server/routes/users-batch/valid.test.ts b/test/server/routes/users-batch/valid.test.ts new file mode 100644 index 0000000..5a53c26 --- /dev/null +++ b/test/server/routes/users-batch/valid.test.ts @@ -0,0 +1,150 @@ +import { assert, describe, it } from 'poku'; +import { + UsersBatchResultSchema, + UsersBatchUpsertResultSchema, +} from '../../../../src/server/routes/users-batch.js'; +import { parseJsonResponse } from '../../helpers.js'; +import { + makeDatabase, + makeRequest, + makeValidBody, + route, +} from './__utils__.js'; + +describe('routes.usersBatch (valid input)', async () => { + await it('returns 201 with code for a single user', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { headers: { ADMIN_KEY: 'secret' } }), + database + ); + + assert.equal(res.status, 201); + const json = await parseJsonResponse( + Promise.resolve(res), + UsersBatchResultSchema + ); + + assert.equal(json.results.length, 1); + const first = json.results[0]; + + assert.equal(first?.email, 'test@example.com'); + assert.equal(first?.code.length, 4); + + const { results } = await database + .prepare('SELECT email, code FROM users WHERE email = ?') + .bind('test@example.com') + .all<{ email: string; code: string }>(); + + assert.equal(results.length, 1); + assert.equal(results[0]?.code, json.results[0]?.code); + }); + + await it('returns 201 with codes for multiple users', async () => { + const database = makeDatabase(); + const body = makeValidBody({ + users: [ + { + email: 'a@example.com', + ticket_type: 1, + votes_allowed: 5, + quantity: 1, + }, + { + email: 'b@example.com', + ticket_type: 2, + votes_allowed: 10, + quantity: 1, + }, + ], + }); + const res = await route( + makeRequest(body, { headers: { ADMIN_KEY: 'secret' } }), + database + ); + + assert.equal(res.status, 201); + const json = await parseJsonResponse( + Promise.resolve(res), + UsersBatchResultSchema + ); + assert.equal(json.results.length, 2); + + const { results } = await database + .prepare('SELECT COUNT(*) as count FROM users') + .bind() + .all<{ count: number }>(); + assert.equal(results[0]?.count, 2); + }); + + await it('upserts duplicate email in batch and returns 201', async () => { + const database = makeDatabase(); + + await route( + makeRequest( + makeValidBody({ + users: [ + { + email: 'dup@example.com', + ticket_type: 1, + votes_allowed: 5, + quantity: 1, + }, + ], + }), + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + const res = await route( + makeRequest( + makeValidBody({ + users: [ + { + email: 'dup@example.com', + ticket_type: 2, + votes_allowed: 8, + quantity: 2, + }, + ], + }), + { headers: { ADMIN_KEY: 'secret' } } + ), + database + ); + + assert.equal(res.status, 201); + const json = await parseJsonResponse( + Promise.resolve(res), + UsersBatchUpsertResultSchema + ); + + assert.equal(json.results.length, 1); + assert.equal(json.results[0]?.email, 'dup@example.com'); + + const { results } = await database + .prepare( + 'SELECT ticket_type, votes_allowed, quantity FROM users WHERE email = ?' + ) + .bind('dup@example.com') + .all<{ ticket_type: number; votes_allowed: number; quantity: number }>(); + + assert.equal(results.length, 1); + assert.deepEqual(results[0], { + ticket_type: 2, + votes_allowed: 13, + quantity: 3, + }); + }); + + await it('works with correct ADMIN_KEY header', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { headers: { ADMIN_KEY: 'secret' } }), + database + ); + + assert.equal(res.status, 201); + }); +}); diff --git a/test/server/routes/voting-state/__utils__.ts b/test/server/routes/voting-state/__utils__.ts new file mode 100644 index 0000000..1ea166a --- /dev/null +++ b/test/server/routes/voting-state/__utils__.ts @@ -0,0 +1,69 @@ +import type { CodeSender } from '../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { votingState } from '../../../../src/server/routes/voting-state.js'; + +const schema = readFileSync( + new URL('../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeRequest = ( + init: { headers?: Record } = {} +): Request => + new Request('http://localhost/api/voting/state', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + }); + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv = (database: AppDatabase): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}); + +export const route = ( + request: Request, + database: AppDatabase +): Promise => { + const env = mockEnv(database); + return votingState({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/voting-state/invalid.test.ts b/test/server/routes/voting-state/invalid.test.ts new file mode 100644 index 0000000..2f1ffd1 --- /dev/null +++ b/test/server/routes/voting-state/invalid.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from 'poku'; +import { generateTokens } from '../../../../src/server/lib/tokens.js'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + +describe('routes.votingState (invalid input)', async () => { + await it('returns 401 when no Authorization header', async () => { + const database = makeDatabase(); + const res = await route(makeRequest(), database); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'unauthorized' }); + }); + + await it('returns 401 when token is invalid', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest({ headers: { Authorization: 'Bearer invalid-token' } }), + database + ); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'unauthorized' }); + }); + + await it('returns 401 when user does not exist', async () => { + const database = makeDatabase(); + const tokens = await generateTokens( + { id: 9999, email: 'ghost@example.com' }, + TEST_SECRET + ); + const res = await route( + makeRequest({ + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + database + ); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'unauthorized' }); + }); +}); diff --git a/test/server/routes/voting-state/valid.test.ts b/test/server/routes/voting-state/valid.test.ts new file mode 100644 index 0000000..b04dcbb --- /dev/null +++ b/test/server/routes/voting-state/valid.test.ts @@ -0,0 +1,146 @@ +import { assert, describe, it } from 'poku'; +import { generateTokens } from '../../../../src/server/lib/tokens.js'; +import { VotingStateResponseSchema } from '../../../../src/server/routes/voting-state.js'; +import { parseJsonResponse } from '../../helpers.js'; +import { makeDatabase, makeRequest, route } from './__utils__.js'; + +const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + +describe('routes.votingState (valid input)', async () => { + const seedData = async (db: ReturnType) => { + await db + .prepare( + `INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + 'Alice', + 'alice@example.com', + '11999999999', + 'São Paulo', + 'SP', + 0, + 2, + 'Bio' + ) + .run(); + + await db + .prepare( + `INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind(1, 0, 'Talk Title', 'Description', 1, 'Reason', 2) + .run(); + + await db + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + }; + + await it('returns 200 with talks and votedTalkIds when user is authenticated and has voted', async () => { + const database = makeDatabase(); + await seedData(database); + + // Cast a vote + await database + .prepare('INSERT INTO votes (user_id, talk_id) VALUES (?, ?)') + .bind(1, 1) + .run(); + + const tokens = await generateTokens( + { id: 1, email: 'user@example.com' }, + TEST_SECRET + ); + const res = await route( + makeRequest({ + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + database + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + VotingStateResponseSchema + ); + assert.equal(json.user.id, 1); + assert.equal(json.user.email, 'user@example.com'); + assert.equal(json.user.votes_remaining, 4); + assert.equal(json.talks.length, 1); + assert.equal(json.talks[0]!.id, 1); + assert.equal(json.talks[0]!.title, 'Talk Title'); + assert.deepEqual(json.votedTalkIds, [1]); + }); + + await it('returns empty votedTalkIds when user has not voted yet', async () => { + const database = makeDatabase(); + await seedData(database); + + const tokens = await generateTokens( + { id: 1, email: 'user@example.com' }, + TEST_SECRET + ); + const res = await route( + makeRequest({ + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + database + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + VotingStateResponseSchema + ); + assert.equal(json.user.votes_remaining, 5); + assert.equal(json.talks.length, 1); + assert.deepEqual(json.votedTalkIds, []); + }); + + await it('returns correct votes_remaining in user object', async () => { + const database = makeDatabase(); + + await database + .prepare( + `INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind('Bob', 'bob@example.com', '11999998888', 'Rio', 'RJ', 0, 1, 'Bio') + .run(); + + await database + .prepare( + `INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind(1, 0, 'Another Talk', 'Desc', 1, 'Reason', 2) + .run(); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user2@example.com', 1, 3, '5678') + .run(); + + const tokens = await generateTokens( + { id: 1, email: 'user2@example.com' }, + TEST_SECRET + ); + const res = await route( + makeRequest({ + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }), + database + ); + + assert.equal(res.status, 200); + const json = await parseJsonResponse( + Promise.resolve(res), + VotingStateResponseSchema + ); + assert.equal(json.user.id, 1); + assert.equal(json.user.email, 'user2@example.com'); + assert.equal(json.user.votes_remaining, 3); + }); +}); diff --git a/test/server/routes/voting-vote/__utils__.ts b/test/server/routes/voting-vote/__utils__.ts new file mode 100644 index 0000000..1015ef5 --- /dev/null +++ b/test/server/routes/voting-vote/__utils__.ts @@ -0,0 +1,119 @@ +import type { CodeSender } from '../../../../src/server/code-sender.js'; +import type { + Database as AppDatabase, + Env, +} from '../../../../src/server/types.js'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { generateTokens } from '../../../../src/server/lib/tokens.js'; +import { + votingRetract, + votingVote, +} from '../../../../src/server/routes/voting-vote.js'; + +const schema = readFileSync( + new URL('../../../../resources/schema.sql', import.meta.url), + 'utf8' +); + +export const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + Vary: 'Origin', +}; + +export const makeDatabase = (): AppDatabase => { + const db = new Database(':memory:'); + db.exec(schema); + + const database: AppDatabase = { + prepare: (sql: string) => ({ + bind: (...values: unknown[]) => ({ + run: async () => db.prepare(sql).run(...values), + all: async () => ({ + results: db.prepare(sql).all(...values) as T[], + }), + }), + }), + }; + + return database; +}; + +export const makeValidBody = (overrides: Record = {}) => ({ + talk_id: 1, + ...overrides, +}); + +export const makeRequest = ( + body: unknown, + init: { method?: string; headers?: Record; raw?: string } = {} +): Request => + new Request('http://localhost/api/voting/vote', { + method: init.method ?? 'POST', + headers: { + 'Content-Type': 'application/json', + ...init.headers, + }, + body: init.raw ?? JSON.stringify(body), + }); + +export const seedSpeakerAndTalk = async (database: AppDatabase) => { + await database + .prepare( + `INSERT INTO speakers (name, email, phone, city, state, travel_pref, experience, bio) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + 'Speaker', + 'speaker@example.com', + '11999999999', + 'São Paulo', + 'SP', + 0, + 2, + 'Bio' + ) + .run(); + + await database + .prepare( + `INSERT INTO talks (speaker_id, duration, title, description, audience_level, reason, status) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind(1, 0, 'Talk Title', 'Description', 1, 'Reason', 2) + .run(); +}; + +export const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const TEST_SECRET = 'test-secret-32-chars-minimum-here'; + +const mockEnv = (database: AppDatabase): Env => ({ + DB: database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: TEST_SECRET, +}); + +export const createAuthHeader = async (userId: number, email: string) => { + const { access_token } = await generateTokens( + { id: userId, email }, + TEST_SECRET + ); + return `Bearer ${access_token}`; +}; + +export const route = ( + request: Request, + database: AppDatabase +): Promise => { + const env = mockEnv(database); + return request.method === 'DELETE' + ? votingRetract({ request, cors, database, env, sender: noopSender }) + : votingVote({ request, cors, database, env, sender: noopSender }); +}; diff --git a/test/server/routes/voting-vote/invalid.test.ts b/test/server/routes/voting-vote/invalid.test.ts new file mode 100644 index 0000000..5156944 --- /dev/null +++ b/test/server/routes/voting-vote/invalid.test.ts @@ -0,0 +1,163 @@ +import { assert, describe, it } from 'poku'; +import { + createAuthHeader, + makeDatabase, + makeRequest, + makeValidBody, + route, + seedSpeakerAndTalk, +} from './__utils__.js'; + +describe('routes.votingVote (invalid input)', async () => { + await describe('transport guards', async () => { + await it('returns 415 for a non-JSON content type', async () => { + const database = makeDatabase(); + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + headers: { + 'Content-Type': 'text/plain', + Authorization: authHeader, + }, + }), + database + ); + + assert.equal(res.status, 415); + }); + + await it('returns 400 for malformed JSON', async () => { + const database = makeDatabase(); + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(null, { + raw: '{not json', + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), { error: 'Invalid JSON.' }); + }); + }); + + await describe('schema rejections', async () => { + await it('returns 422 for missing talk_id field', async () => { + const database = makeDatabase(); + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest({}, { headers: { Authorization: authHeader } }), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 when talk_id is not positive', async () => { + const database = makeDatabase(); + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest({ talk_id: 0 }, { headers: { Authorization: authHeader } }), + database + ); + + assert.equal(res.status, 422); + }); + }); + + await describe('business rules', async () => { + await it('returns 401 when Authorization header is missing', async () => { + const database = makeDatabase(); + const res = await route(makeRequest({ talk_id: 1 }), database); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'unauthorized' }); + }); + + await it('returns 409 when already voted for this talk', async () => { + const database = makeDatabase(); + await seedSpeakerAndTalk(database); + + // Seed user + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + // Seed vote + await database + .prepare(`INSERT INTO votes (user_id, talk_id) VALUES (?, ?)`) + .bind(1, 1) + .run(); + + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { + error: 'Already voted for this talk', + }); + }); + + await it('returns 403 when no votes remaining', async () => { + const database = makeDatabase(); + await seedSpeakerAndTalk(database); + + // Seed user with 1 vote allowed and cast it + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 1, '1234') + .run(); + + await database + .prepare(`INSERT INTO votes (user_id, talk_id) VALUES (?, ?)`) + .bind(1, 1) + .run(); + + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 403); + assert.deepEqual(await res.json(), { error: 'No votes remaining' }); + }); + }); +}); diff --git a/test/server/routes/voting-vote/retract.test.ts b/test/server/routes/voting-vote/retract.test.ts new file mode 100644 index 0000000..b09ed45 --- /dev/null +++ b/test/server/routes/voting-vote/retract.test.ts @@ -0,0 +1,117 @@ +import { assert, describe, it } from 'poku'; +import { + createAuthHeader, + makeDatabase, + makeRequest, + makeValidBody, + route, + seedSpeakerAndTalk, +} from './__utils__.js'; + +describe('routes.votingRetract', async () => { + await describe('transport and schema', async () => { + await it('returns 422 for missing talk_id on DELETE', async () => { + const database = makeDatabase(); + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest( + {}, + { + method: 'DELETE', + headers: { Authorization: authHeader }, + } + ), + database + ); + + assert.equal(res.status, 422); + }); + + await it('returns 422 for missing talk_id on DELETE (no auth)', async () => { + const database = makeDatabase(); + const res = await route(makeRequest({}, { method: 'DELETE' }), database); + + assert.equal(res.status, 401); + }); + }); + + await describe('business rules', async () => { + await it('returns 401 when no auth header on DELETE', async () => { + const database = makeDatabase(); + const res = await route( + makeRequest(makeValidBody(), { method: 'DELETE' }), + database + ); + + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: 'unauthorized' }); + }); + + await it('returns 404 when vote not found on DELETE', async () => { + const database = makeDatabase(); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + method: 'DELETE', + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 404); + assert.deepEqual(await res.json(), { error: 'Vote not found' }); + }); + }); + + await describe('happy path', async () => { + await it('retracts a vote and returns 200 with votes_remaining', async () => { + const database = makeDatabase(); + await seedSpeakerAndTalk(database); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + await database + .prepare('INSERT INTO votes (user_id, talk_id) VALUES (?, ?)') + .bind(1, 1) + .run(); + + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + method: 'DELETE', + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { votes_remaining: 5 }); + + const { results } = await database + .prepare('SELECT * FROM votes WHERE user_id = ? AND talk_id = ?') + .bind(1, 1) + .all(); + + assert.equal(results.length, 0); + }); + }); +}); diff --git a/test/server/routes/voting-vote/valid.test.ts b/test/server/routes/voting-vote/valid.test.ts new file mode 100644 index 0000000..ec9130d --- /dev/null +++ b/test/server/routes/voting-vote/valid.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from 'poku'; +import { + createAuthHeader, + makeDatabase, + makeRequest, + makeValidBody, + route, + seedSpeakerAndTalk, +} from './__utils__.js'; + +describe('routes.votingVote (valid input)', async () => { + await it('casts a vote and returns 200 with votes_remaining', async () => { + const database = makeDatabase(); + await seedSpeakerAndTalk(database); + + await database + .prepare( + `INSERT INTO users (email, ticket_type, votes_allowed, code) VALUES (?, ?, ?, ?)` + ) + .bind('user@example.com', 1, 5, '1234') + .run(); + + const authHeader = await createAuthHeader(1, 'user@example.com'); + const res = await route( + makeRequest(makeValidBody(), { + headers: { Authorization: authHeader }, + }), + database + ); + + assert.equal(res.status, 200); + const json = await res.json(); + + assert.deepEqual(json, { votes_remaining: 4 }); + + const { results } = await database + .prepare( + 'SELECT user_id, talk_id FROM votes WHERE user_id = ? AND talk_id = ?' + ) + .bind(1, 1) + .all<{ user_id: number; talk_id: number }>(); + + assert.equal(results.length, 1); + assert.deepEqual(results[0], { user_id: 1, talk_id: 1 }); + }); +}); diff --git a/test/server/routes/waitlist.test.ts b/test/server/routes/waitlist.test.ts index 751d117..9aec607 100644 --- a/test/server/routes/waitlist.test.ts +++ b/test/server/routes/waitlist.test.ts @@ -1,4 +1,5 @@ -import type { Database } from '../../../src/server/types.js'; +import type { CodeSender } from '../../../src/server/code-sender.js'; +import type { Database, Env } from '../../../src/server/types.js'; import { assert, beforeEach, describe, it } from 'poku'; import { routes } from '../../../src/server/routes.js'; @@ -49,6 +50,18 @@ const makeRequest = (body: unknown): Request => body: JSON.stringify(body), }); +const noopSender: CodeSender = { + sendCode: async () => {}, +}; + +const mockEnv: Env = { + DB: makeMockDatabase().database, + STRIPE_SECRET_KEY: 'test_key', + STRIPE_WEBHOOK_SECRET: 'test_secret', + ADMIN_KEY: 'test_admin', + JWT_SECRET: 'test-secret-32-chars-minimum-here', +}; + const post = async ( body: unknown, mock: MockDatabase, @@ -59,6 +72,8 @@ const post = async ( cors, database: mock.database, ip, + env: mockEnv, + sender: noopSender, }); describe('routes.waitlist', async () => { @@ -67,7 +82,10 @@ describe('routes.waitlist', async () => { const mock = makeMockDatabase(); const res = await post({}, mock); assert.equal(res.status, 422); - assert.deepEqual(await res.json(), { error: 'Invalid input.' }); + assert.equal( + ((await res.json()) as { error: string }).error, + 'Validation failed' + ); }); await it('returns 422 for an invalid email format', async () => { diff --git a/test/server/schema.test.ts b/test/server/schema.test.ts index f5ad244..472ba02 100644 --- a/test/server/schema.test.ts +++ b/test/server/schema.test.ts @@ -1,5 +1,7 @@ import type { Database } from '../../src/server/types.js'; import { assert, describe, it } from 'poku'; +import { user } from '../../src/server/repositories/user.js'; +import { vote } from '../../src/server/repositories/vote.js'; import { waitlist } from '../../src/server/repositories/waitlist.js'; const stub = { @@ -66,4 +68,96 @@ describe('schema', () => { if (result.success) assert.equal(result.data.website, 'http://spam.io'); }); }); + + describe('user schema', () => { + const { schema: userSchema } = user(stub); + + it('accepts valid user data', () => { + const result = userSchema.safeParse({ + email: 'user@example.com', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(result.success); + }); + + it('rejects invalid email', () => { + const result = userSchema.safeParse({ + email: 'invalid', + ticket_type: 1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('rejects negative ticket_type', () => { + const result = userSchema.safeParse({ + email: 'user@example.com', + ticket_type: -1, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('accepts zero ticket_type', () => { + const result = userSchema.safeParse({ + email: 'user@example.com', + ticket_type: 0, + votes_allowed: 3, + quantity: 1, + }); + assert.ok(result.success); + }); + + it('rejects negative votes_allowed', () => { + const result = userSchema.safeParse({ + email: 'user@example.com', + ticket_type: 1, + votes_allowed: -1, + quantity: 1, + }); + assert.ok(!result.success); + }); + + it('accepts zero votes_allowed', () => { + const result = userSchema.safeParse({ + email: 'user@example.com', + ticket_type: 1, + votes_allowed: 0, + quantity: 1, + }); + assert.ok(result.success); + }); + }); + + describe('vote schema', () => { + const { schema: voteSchema } = vote(stub); + + it('accepts valid vote data', () => { + const result = voteSchema.safeParse({ + code: '1234', + talk_id: 1, + }); + assert.ok(result.success); + }); + + it('rejects non-4-char code', () => { + const result = voteSchema.safeParse({ + code: '12', + talk_id: 1, + }); + assert.ok(!result.success); + }); + + it('rejects non-positive talk_id', () => { + const result = voteSchema.safeParse({ + code: '1234', + talk_id: 0, + }); + assert.ok(!result.success); + }); + }); });