mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(events): add mana-events service + public RSVP flow (Phase 1b)
New Hono+Bun service at services/mana-events on port 3065 with two schemas in mana_platform: events_published (snapshots) and public_rsvps (unauthenticated responses), plus a per-token hourly rate-limit bucket. - Host endpoints (JWT) for publish/update/unpublish/list-rsvps - Public endpoints for snapshot fetch + RSVP upsert with rate limiting - New /rsvp/[token] page outside the auth gate, SSR-loads the snapshot - Client store wires publishEvent/unpublishEvent to the server, syncs snapshot updates after edits, and deletes the snapshot on event delete - DetailView polls GET /events/:id/rsvps every 30s while open and lets hosts import a public response into their local guest list - generate-env, setup-databases.sh, .env.development, hooks.server.ts, package.json wired for local dev
This commit is contained in:
parent
980a5e996c
commit
216746721e
27 changed files with 1764 additions and 11 deletions
16
services/mana-events/Dockerfile
Normal file
16
services/mana-events/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM oven/bun:1 AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json drizzle.config.ts ./
|
||||
|
||||
EXPOSE 3065
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3065/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
165
services/mana-events/bun.lock
Normal file
165
services/mana-events/bun.lock
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@mana/events",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"postgres": "^3.4.5",
|
||||
"zod": "^3.24.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="],
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.38.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
|
||||
|
||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||
|
||||
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="],
|
||||
|
||||
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
}
|
||||
}
|
||||
11
services/mana-events/drizzle.config.ts
Normal file
11
services/mana-events/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/*.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
|
||||
},
|
||||
schemaFilter: ['events'],
|
||||
});
|
||||
24
services/mana-events/package.json
Normal file
24
services/mana-events/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@mana/events",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"jose": "^6.1.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
42
services/mana-events/src/config.ts
Normal file
42
services/mana-events/src/config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Application configuration loaded from environment variables.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
cors: {
|
||||
origins: string[];
|
||||
};
|
||||
rateLimit: {
|
||||
// Max public RSVP submissions per token per hour
|
||||
rsvpPerTokenPerHour: number;
|
||||
// Hard cap on total RSVPs per token
|
||||
rsvpMaxPerToken: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const requiredEnv = (key: string, fallback?: string): string => {
|
||||
const value = process.env[key] || fallback;
|
||||
if (!value) throw new Error(`Missing required env var: ${key}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3065', 10),
|
||||
databaseUrl: requiredEnv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
||||
),
|
||||
manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
|
||||
},
|
||||
rateLimit: {
|
||||
rsvpPerTokenPerHour: parseInt(process.env.RSVP_RATE_LIMIT || '60', 10),
|
||||
rsvpMaxPerToken: parseInt(process.env.RSVP_MAX_PER_TOKEN || '500', 10),
|
||||
},
|
||||
};
|
||||
}
|
||||
19
services/mana-events/src/db/connection.ts
Normal file
19
services/mana-events/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Database connection using Drizzle ORM + postgres.js
|
||||
*/
|
||||
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema/index';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const client = postgres(databaseUrl, { max: 10 });
|
||||
db = drizzle(client, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
84
services/mana-events/src/db/schema/events.ts
Normal file
84
services/mana-events/src/db/schema/events.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Events schema — published event snapshots and public RSVP responses.
|
||||
*
|
||||
* `events_published` is a server-side cache of an event's public-facing
|
||||
* metadata, written by the host's client when they "publish" the event.
|
||||
* It is the source of truth that the public RSVP page reads from.
|
||||
*/
|
||||
|
||||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const eventsSchema = pgSchema('events');
|
||||
|
||||
/** Published event snapshots — one per token. */
|
||||
export const eventsPublished = eventsSchema.table(
|
||||
'events_published',
|
||||
{
|
||||
token: text('token').primaryKey(),
|
||||
eventId: uuid('event_id').notNull(),
|
||||
userId: text('user_id').notNull(), // host
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
location: text('location'),
|
||||
locationUrl: text('location_url'),
|
||||
startAt: timestamp('start_at', { withTimezone: true }).notNull(),
|
||||
endAt: timestamp('end_at', { withTimezone: true }),
|
||||
allDay: boolean('all_day').default(false).notNull(),
|
||||
coverImageUrl: text('cover_image_url'),
|
||||
color: text('color'),
|
||||
capacity: integer('capacity'),
|
||||
isCancelled: boolean('is_cancelled').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdIdx: index('events_published_user_id_idx').on(t.userId),
|
||||
eventIdIdx: index('events_published_event_id_idx').on(t.eventId),
|
||||
})
|
||||
);
|
||||
|
||||
/** Public RSVP responses — submitted via the share link, no auth. */
|
||||
export const publicRsvps = eventsSchema.table(
|
||||
'public_rsvps',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
token: text('token')
|
||||
.notNull()
|
||||
.references(() => eventsPublished.token, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
email: text('email'),
|
||||
status: text('status').notNull(), // 'yes' | 'no' | 'maybe'
|
||||
plusOnes: integer('plus_ones').default(0).notNull(),
|
||||
note: text('note'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
tokenIdx: index('public_rsvps_token_idx').on(t.token),
|
||||
// Best-effort dedup: same token + same lowercase name + same lowercase email = same person.
|
||||
// Email may be null, so we coalesce to '' for the index.
|
||||
uniquePerson: uniqueIndex('public_rsvps_token_name_email_unique').on(t.token, t.name, t.email),
|
||||
})
|
||||
);
|
||||
|
||||
/** Per-token rate limit bucket — token + hour-bucket → submission count. */
|
||||
export const rsvpRateBuckets = eventsSchema.table(
|
||||
'rsvp_rate_buckets',
|
||||
{
|
||||
token: text('token').notNull(),
|
||||
hourBucket: text('hour_bucket').notNull(), // YYYY-MM-DDTHH
|
||||
count: integer('count').default(0).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: uniqueIndex('rsvp_rate_buckets_pk').on(t.token, t.hourBucket),
|
||||
})
|
||||
);
|
||||
1
services/mana-events/src/db/schema/index.ts
Normal file
1
services/mana-events/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './events';
|
||||
47
services/mana-events/src/index.ts
Normal file
47
services/mana-events/src/index.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* mana-events — Public RSVP & event sharing service.
|
||||
*
|
||||
* Hono + Bun runtime. Stores published event snapshots and the public
|
||||
* RSVP responses they collect. Hosts authenticate via mana-auth JWT;
|
||||
* RSVP endpoints are intentionally unauthenticated so anyone with a
|
||||
* share link can respond.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createEventsRoutes } from './routes/events';
|
||||
import { createRsvpRoutes } from './routes/rsvp';
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Public — no auth
|
||||
app.route('/health', healthRoutes);
|
||||
app.route('/api/v1/rsvp', createRsvpRoutes(db, config));
|
||||
|
||||
// Authenticated host endpoints
|
||||
app.use('/api/v1/events/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/events', createEventsRoutes(db));
|
||||
|
||||
console.log(`mana-events starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
31
services/mana-events/src/lib/errors.ts
Normal file
31
services/mana-events/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message: string) {
|
||||
super(400, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HTTPException {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class TooManyRequestsError extends HTTPException {
|
||||
constructor(message = 'Rate limit exceeded') {
|
||||
super(429, { message });
|
||||
}
|
||||
}
|
||||
10
services/mana-events/src/middleware/error-handler.ts
Normal file
10
services/mana-events/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
||||
}
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
|
||||
};
|
||||
50
services/mana-events/src/middleware/jwt-auth.ts
Normal file
50
services/mana-events/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* JWT Authentication Middleware — validates Bearer tokens via JWKS from mana-auth.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'mana',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
175
services/mana-events/src/routes/events.ts
Normal file
175
services/mana-events/src/routes/events.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Host event routes — JWT-authenticated.
|
||||
*
|
||||
* Lets the event organizer publish a snapshot of their event, update it,
|
||||
* unpublish (delete) it, and read back the public RSVPs they've received.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { eventsPublished, publicRsvps } from '../db/schema/events';
|
||||
import { ForbiddenError, NotFoundError, BadRequestError } from '../lib/errors';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
const snapshotSchema = z.object({
|
||||
eventId: z.string().uuid(),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
location: z.string().max(500).nullable().optional(),
|
||||
locationUrl: z.string().url().max(2000).nullable().optional(),
|
||||
startAt: z.string().datetime(),
|
||||
endAt: z.string().datetime().nullable().optional(),
|
||||
allDay: z.boolean().optional(),
|
||||
coverImageUrl: z.string().url().max(2000).nullable().optional(),
|
||||
color: z.string().max(20).nullable().optional(),
|
||||
capacity: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
const snapshotUpdateSchema = snapshotSchema.partial().extend({
|
||||
eventId: z.string().uuid(), // still required so we can verify ownership
|
||||
});
|
||||
|
||||
function generateToken(): string {
|
||||
// 24-char URL-safe random
|
||||
const bytes = new Uint8Array(18);
|
||||
crypto.getRandomValues(bytes);
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
export function createEventsRoutes(db: Database) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// POST /events/publish — create a new published snapshot
|
||||
app.post('/publish', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = snapshotSchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
// Reuse existing token if this event was previously published
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.eventId, parsed.data.eventId))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event');
|
||||
await db
|
||||
.update(eventsPublished)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
location: parsed.data.location ?? null,
|
||||
locationUrl: parsed.data.locationUrl ?? null,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
endAt: parsed.data.endAt ? new Date(parsed.data.endAt) : null,
|
||||
allDay: parsed.data.allDay ?? false,
|
||||
coverImageUrl: parsed.data.coverImageUrl ?? null,
|
||||
color: parsed.data.color ?? null,
|
||||
capacity: parsed.data.capacity ?? null,
|
||||
isCancelled: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(eventsPublished.token, existing[0].token));
|
||||
return c.json({ token: existing[0].token, isNew: false });
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
await db.insert(eventsPublished).values({
|
||||
token,
|
||||
eventId: parsed.data.eventId,
|
||||
userId: user.userId,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
location: parsed.data.location ?? null,
|
||||
locationUrl: parsed.data.locationUrl ?? null,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
endAt: parsed.data.endAt ? new Date(parsed.data.endAt) : null,
|
||||
allDay: parsed.data.allDay ?? false,
|
||||
coverImageUrl: parsed.data.coverImageUrl ?? null,
|
||||
color: parsed.data.color ?? null,
|
||||
capacity: parsed.data.capacity ?? null,
|
||||
});
|
||||
return c.json({ token, isNew: true });
|
||||
});
|
||||
|
||||
// PUT /events/:eventId/snapshot — update an existing snapshot (alias of publish)
|
||||
app.put('/:eventId/snapshot', async (c) => {
|
||||
const user = c.get('user');
|
||||
const eventId = c.req.param('eventId');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = snapshotUpdateSchema.safeParse({ ...body, eventId });
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.eventId, eventId))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Event not published');
|
||||
if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event');
|
||||
|
||||
const updates: Partial<typeof eventsPublished.$inferInsert> = { updatedAt: new Date() };
|
||||
if (parsed.data.title !== undefined) updates.title = parsed.data.title;
|
||||
if (parsed.data.description !== undefined) updates.description = parsed.data.description;
|
||||
if (parsed.data.location !== undefined) updates.location = parsed.data.location;
|
||||
if (parsed.data.locationUrl !== undefined) updates.locationUrl = parsed.data.locationUrl;
|
||||
if (parsed.data.startAt !== undefined) updates.startAt = new Date(parsed.data.startAt);
|
||||
if (parsed.data.endAt !== undefined)
|
||||
updates.endAt = parsed.data.endAt ? new Date(parsed.data.endAt) : null;
|
||||
if (parsed.data.allDay !== undefined) updates.allDay = parsed.data.allDay;
|
||||
if (parsed.data.coverImageUrl !== undefined) updates.coverImageUrl = parsed.data.coverImageUrl;
|
||||
if (parsed.data.color !== undefined) updates.color = parsed.data.color;
|
||||
if (parsed.data.capacity !== undefined) updates.capacity = parsed.data.capacity;
|
||||
|
||||
await db
|
||||
.update(eventsPublished)
|
||||
.set(updates)
|
||||
.where(eq(eventsPublished.token, existing[0].token));
|
||||
return c.json({ token: existing[0].token });
|
||||
});
|
||||
|
||||
// DELETE /events/:eventId — unpublish (cascade-deletes RSVPs)
|
||||
app.delete('/:eventId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const eventId = c.req.param('eventId');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.eventId, eventId))
|
||||
.limit(1);
|
||||
if (!existing[0]) return c.json({ deleted: false });
|
||||
if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event');
|
||||
|
||||
await db.delete(eventsPublished).where(eq(eventsPublished.token, existing[0].token));
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
// GET /events/:eventId/rsvps — list all RSVPs for the host
|
||||
app.get('/:eventId/rsvps', async (c) => {
|
||||
const user = c.get('user');
|
||||
const eventId = c.req.param('eventId');
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.eventId, eventId))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new NotFoundError('Event not published');
|
||||
if (existing[0].userId !== user.userId) throw new ForbiddenError('Not your event');
|
||||
|
||||
const rsvps = await db
|
||||
.select()
|
||||
.from(publicRsvps)
|
||||
.where(eq(publicRsvps.token, existing[0].token));
|
||||
return c.json({ token: existing[0].token, rsvps });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
5
services/mana-events/src/routes/health.ts
Normal file
5
services/mana-events/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({ status: 'ok', service: 'mana-events', timestamp: new Date().toISOString() })
|
||||
);
|
||||
169
services/mana-events/src/routes/rsvp.ts
Normal file
169
services/mana-events/src/routes/rsvp.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Public RSVP routes — no authentication.
|
||||
*
|
||||
* Anyone with a share link can view the event snapshot and submit an RSVP.
|
||||
* Protected by per-token rate limiting and a hard total cap.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { eventsPublished, publicRsvps, rsvpRateBuckets } from '../db/schema/events';
|
||||
import { NotFoundError, BadRequestError, TooManyRequestsError } from '../lib/errors';
|
||||
import type { Config } from '../config';
|
||||
|
||||
const rsvpBodySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email().max(200).optional().nullable(),
|
||||
status: z.enum(['yes', 'no', 'maybe']),
|
||||
plusOnes: z.number().int().min(0).max(20).optional().default(0),
|
||||
note: z.string().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
function currentHourBucket(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}`;
|
||||
}
|
||||
|
||||
export function createRsvpRoutes(db: Database, config: Config) {
|
||||
const app = new Hono();
|
||||
|
||||
// GET /rsvp/:token — public event snapshot + summary
|
||||
app.get('/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.token, token))
|
||||
.limit(1);
|
||||
const event = rows[0];
|
||||
if (!event) throw new NotFoundError('Event not found');
|
||||
if (event.isCancelled) {
|
||||
return c.json({ event: { ...event, isCancelled: true }, summary: null, cancelled: true });
|
||||
}
|
||||
|
||||
// Compute summary (counts only — never expose individual responses publicly)
|
||||
const all = await db
|
||||
.select({ status: publicRsvps.status, plusOnes: publicRsvps.plusOnes })
|
||||
.from(publicRsvps)
|
||||
.where(eq(publicRsvps.token, token));
|
||||
|
||||
const summary = { yes: 0, no: 0, maybe: 0, totalAttending: 0 };
|
||||
for (const r of all) {
|
||||
if (r.status === 'yes') {
|
||||
summary.yes++;
|
||||
summary.totalAttending += 1 + (r.plusOnes ?? 0);
|
||||
} else if (r.status === 'no') summary.no++;
|
||||
else if (r.status === 'maybe') summary.maybe++;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
event: {
|
||||
token: event.token,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
allDay: event.allDay,
|
||||
coverImageUrl: event.coverImageUrl,
|
||||
color: event.color,
|
||||
capacity: event.capacity,
|
||||
},
|
||||
summary,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /rsvp/:token — submit/update an RSVP
|
||||
app.post('/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const parsed = rsvpBodySchema.safeParse(body);
|
||||
if (!parsed.success) throw new BadRequestError(parsed.error.issues[0]?.message ?? 'Invalid');
|
||||
|
||||
// Verify event exists & isn't cancelled
|
||||
const eventRows = await db
|
||||
.select()
|
||||
.from(eventsPublished)
|
||||
.where(eq(eventsPublished.token, token))
|
||||
.limit(1);
|
||||
const event = eventRows[0];
|
||||
if (!event) throw new NotFoundError('Event not found');
|
||||
if (event.isCancelled) throw new BadRequestError('Event has been cancelled');
|
||||
|
||||
// Hard total-cap check
|
||||
const totalRows = await db
|
||||
.select({ c: sql<number>`count(*)::int` })
|
||||
.from(publicRsvps)
|
||||
.where(eq(publicRsvps.token, token));
|
||||
const total = totalRows[0]?.c ?? 0;
|
||||
if (total >= config.rateLimit.rsvpMaxPerToken) {
|
||||
throw new TooManyRequestsError('Maximum RSVPs reached for this event');
|
||||
}
|
||||
|
||||
// Per-token hourly rate limit
|
||||
const bucket = currentHourBucket();
|
||||
const bucketRows = await db
|
||||
.select()
|
||||
.from(rsvpRateBuckets)
|
||||
.where(and(eq(rsvpRateBuckets.token, token), eq(rsvpRateBuckets.hourBucket, bucket)))
|
||||
.limit(1);
|
||||
const currentCount = bucketRows[0]?.count ?? 0;
|
||||
if (currentCount >= config.rateLimit.rsvpPerTokenPerHour) {
|
||||
throw new TooManyRequestsError('Too many submissions, please try again later');
|
||||
}
|
||||
|
||||
// Upsert RSVP — same (token, name, email) overwrites
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(publicRsvps)
|
||||
.where(
|
||||
and(
|
||||
eq(publicRsvps.token, token),
|
||||
eq(publicRsvps.name, parsed.data.name),
|
||||
parsed.data.email
|
||||
? eq(publicRsvps.email, parsed.data.email)
|
||||
: sql`${publicRsvps.email} is null`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await db
|
||||
.update(publicRsvps)
|
||||
.set({
|
||||
status: parsed.data.status,
|
||||
plusOnes: parsed.data.plusOnes ?? 0,
|
||||
note: parsed.data.note ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(publicRsvps.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(publicRsvps).values({
|
||||
token,
|
||||
name: parsed.data.name,
|
||||
email: parsed.data.email ?? null,
|
||||
status: parsed.data.status,
|
||||
plusOnes: parsed.data.plusOnes ?? 0,
|
||||
note: parsed.data.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Increment rate bucket
|
||||
if (bucketRows[0]) {
|
||||
await db
|
||||
.update(rsvpRateBuckets)
|
||||
.set({ count: bucketRows[0].count + 1 })
|
||||
.where(and(eq(rsvpRateBuckets.token, token), eq(rsvpRateBuckets.hourBucket, bucket)));
|
||||
} else {
|
||||
await db.insert(rsvpRateBuckets).values({ token, hourBucket: bucket, count: 1 });
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
17
services/mana-events/tsconfig.json
Normal file
17
services/mana-events/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue