From 34ea15a54d1670b628260fba9d3f2c26fb6fe27f Mon Sep 17 00:00:00 2001 From: AnuragJangid18 Date: Thu, 8 Oct 2026 02:52:58 +0530 Subject: [PATCH] first commit --- .gitignore | 37 + README.md | 91 + backend/.env.example | 50 + backend/Dockerfile | 18 + backend/README.md | 67 + backend/alembic.ini | 42 + backend/alembic/env.py | 67 + backend/alembic/script.py.mako | 26 + .../20260404_0001_edition_tickets_meta.py | 31 + .../versions/20260404_0002_outbound_clicks.py | 39 + .../20260404_0003_phase4_roles_subscribers.py | 56 + .../20260404_0004_platform_highape.py | 22 + .../20260404_0005_events_venues_catalog.py | 123 + .../alembic/versions/20260404_0006_users.py | 34 + .../versions/20260404_0007_orders_tickets.py | 129 + .../versions/20260404_0008_payments.py | 73 + .../versions/20260404_0009_digital_tickets.py | 42 + backend/app/__init__.py | 1 + backend/app/api/__init__.py | 3 + backend/app/api/router.py | 14 + backend/app/api/routes/__init__.py | 0 backend/app/api/routes/admin/router.py | 157 + backend/app/api/routes/auth.py | 42 + backend/app/api/routes/events.py | 33 + backend/app/api/routes/health.py | 24 + backend/app/api/routes/orders.py | 72 + backend/app/api/routes/tickets.py | 37 + backend/app/api/routes/venues.py | 18 + backend/app/api/routes/webhooks.py | 31 + backend/app/auth.py | 56 + backend/app/config.py | 5 + backend/app/core/__init__.py | 5 + backend/app/core/config.py | 105 + backend/app/core/deps.py | 76 + backend/app/core/errors.py | 23 + backend/app/core/rate_limit.py | 37 + backend/app/core/security.py | 72 + backend/app/database.py | 6 + backend/app/db/__init__.py | 4 + backend/app/db/base.py | 5 + backend/app/db/session.py | 23 + backend/app/main.py | 43 + backend/app/models/__init__.py | 50 + backend/app/models/catalog.py | 115 + backend/app/models/entities.py | 105 + backend/app/models/enums.py | 101 + backend/app/models/orders.py | 89 + backend/app/models/payments.py | 19 + backend/app/models/user.py | 19 + backend/app/routers/__init__.py | 1 + backend/app/routers/admin.py | 238 + backend/app/routers/admin_users.py | 44 + backend/app/routers/analytics.py | 40 + backend/app/routers/auth.py | 17 + backend/app/routers/public.py | 151 + backend/app/routers/share.py | 13 + backend/app/schemas/__init__.py | 3 + backend/app/schemas/admin.py | 181 + backend/app/schemas/auth.py | 47 + backend/app/schemas/catalog.py | 62 + backend/app/schemas/domain.py | 170 + backend/app/schemas/health.py | 7 + backend/app/schemas/orders.py | 90 + backend/app/schemas/tickets.py | 41 + backend/app/services/__init__.py | 1 + backend/app/services/admin/__init__.py | 0 backend/app/services/admin/catalog.py | 126 + backend/app/services/admin/dashboard.py | 82 + backend/app/services/admin/orders.py | 40 + backend/app/services/alerts.py | 39 + backend/app/services/calendar.py | 38 + backend/app/services/catalog.py | 95 + backend/app/services/editions.py | 60 + backend/app/services/email.py | 21 + backend/app/services/health.py | 11 + backend/app/services/media.py | 70 + backend/app/services/orders.py | 335 ++ backend/app/services/outbound.py | 10 + backend/app/services/payments/__init__.py | 3 + backend/app/services/payments/gateway.py | 220 + backend/app/services/payments/service.py | 203 + backend/app/services/qr.py | 16 + backend/app/services/share_preview.py | 37 + backend/app/services/tickets.py | 142 + backend/app/services/users.py | 41 + backend/app/storage.py | 5 + backend/app/utils/__init__.py | 0 backend/app/utils/storage.py | 74 + backend/requirements.txt | 19 + backend/scripts/migrate.py | 20 + backend/scripts/seed.py | 55 + backend/scripts/seed_admin.py | 40 + backend/scripts/seed_catalog.py | 187 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 4 + backend/tests/helpers.py | 56 + backend/tests/test_admin_api.py | 50 + backend/tests/test_auth.py | 95 + backend/tests/test_catalog_api.py | 64 + backend/tests/test_digital_tickets.py | 61 + backend/tests/test_health.py | 19 + backend/tests/test_orders.py | 174 + backend/tests/test_payments.py | 97 + backend/tests/test_security.py | 55 + backend/tests/test_security_hardening.py | 50 + docker-compose.yml | 20 + docs/API_SPEC.md | 586 +++ docs/ARCHITECTURE.md | 275 ++ docs/DATABASE.md | 210 + docs/DESIGN_SYSTEM.md | 188 + docs/PERFORMANCE.md | 73 + docs/PRD.md | 185 + docs/PROGRESS.md | 414 ++ docs/SECURITY.md | 194 + frontend/.env.example | 6 + frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 40 + frontend/index.html | 31 + frontend/package-lock.json | 4231 +++++++++++++++++ frontend/package.json | 33 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.jsx | 47 + frontend/src/admin/AdminAuthContext.jsx | 71 + frontend/src/admin/AdminProtectedRoute.jsx | 19 + frontend/src/admin/AdminRoutes.jsx | 36 + frontend/src/admin/api.js | 28 + frontend/src/admin/components/AdminField.jsx | 12 + frontend/src/admin/components/AdminLayout.jsx | 54 + .../src/admin/components/AdminPageHeader.jsx | 11 + frontend/src/admin/components/AdminTable.jsx | 29 + .../src/admin/pages/AdminDashboardPage.jsx | 44 + frontend/src/admin/pages/AdminEventsPage.jsx | 94 + frontend/src/admin/pages/AdminLoginPage.jsx | 48 + frontend/src/admin/pages/AdminOrdersPage.jsx | 47 + .../src/admin/pages/AdminPaymentsPage.jsx | 32 + frontend/src/admin/pages/AdminStatsPage.jsx | 33 + .../src/admin/pages/AdminTicketTypesPage.jsx | 67 + frontend/src/admin/pages/AdminVenuesPage.jsx | 45 + frontend/src/admin/pages/AdminVerifyPage.jsx | 48 + frontend/src/admin/token.js | 26 + frontend/src/animations/gsap.js | 3 + frontend/src/animations/loadGsap.js | 22 + .../animations/useEventDetailAnimations.js | 96 + .../src/animations/useHomeScrollAnimations.js | 95 + frontend/src/api/client.js | 1 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + .../src/components/auth/AuthFormLayout.jsx | 19 + .../src/components/auth/ProtectedRoute.jsx | 24 + frontend/src/components/cards/EventCard.jsx | 100 + .../src/components/common/RequestState.jsx | 55 + .../src/components/common/RouteFallback.jsx | 6 + .../src/components/event/EventDescription.jsx | 22 + .../components/event/EventDetailGallery.jsx | 43 + .../components/event/EventDetailHeader.jsx | 18 + .../src/components/event/EventDetailHero.jsx | 50 + frontend/src/components/event/EventFaq.jsx | 32 + .../src/components/event/EventHighlights.jsx | 24 + .../components/event/EventMobileTicketBar.jsx | 24 + .../event/EventScheduleLocation.jsx | 35 + .../src/components/event/EventTicketCta.jsx | 35 + .../src/components/event/EventTicketTiers.jsx | 66 + .../src/components/event/EventVenueInfo.jsx | 43 + frontend/src/components/event/index.js | 11 + .../src/components/home/BrandStorySection.jsx | 38 + .../src/components/home/CategoriesSection.jsx | 48 + .../components/home/FeaturedEventSection.jsx | 81 + .../src/components/home/FinalCtaSection.jsx | 62 + .../src/components/home/GallerySection.jsx | 90 + frontend/src/components/home/HomeHero.jsx | 102 + .../src/components/home/MarketingFooter.jsx | 80 + .../components/home/UpcomingEventsSection.jsx | 43 + .../src/components/home/VenuesSection.jsx | 56 + frontend/src/components/index.js | 16 + .../src/components/layout/SectionSplit.jsx | 19 + .../components/navigation/MarketingNavbar.jsx | 195 + .../src/components/navigation/NavLink.jsx | 20 + frontend/src/components/navigation/Navbar.jsx | 76 + .../src/components/navigation/SiteFooter.jsx | 20 + .../src/components/tickets/DigitalTicket.jsx | 78 + .../src/components/typography/Eyebrow.jsx | 15 + .../src/components/typography/Heading.jsx | 16 + frontend/src/components/typography/Text.jsx | 24 + frontend/src/components/ui/Badge.jsx | 25 + frontend/src/components/ui/Button.jsx | 67 + frontend/src/components/ui/Card.jsx | 39 + frontend/src/components/ui/Container.jsx | 24 + frontend/src/components/ui/EmptyState.jsx | 45 + frontend/src/components/ui/Section.jsx | 63 + frontend/src/components/ui/Skeleton.jsx | 29 + frontend/src/components/ui/Spinner.jsx | 22 + frontend/src/context/AuthContext.jsx | 88 + frontend/src/data/eventsMock.js | 251 + frontend/src/data/homeMock.js | 162 + frontend/src/hooks/.gitkeep | 0 frontend/src/hooks/useAsyncQuery.js | 42 + frontend/src/hooks/useBreakpoint.js | 43 + frontend/src/hooks/useCheckout.js | 111 + frontend/src/hooks/useDocumentTitle.js | 11 + frontend/src/hooks/useEvents.js | 92 + frontend/src/hooks/useVenues.js | 19 + frontend/src/layouts/AppLayout.jsx | 21 + frontend/src/layouts/MarketingLayout.jsx | 27 + frontend/src/main.jsx | 17 + frontend/src/pages/AccountPage.jsx | 82 + frontend/src/pages/DesignSystemPage.jsx | 193 + frontend/src/pages/EventDetailsPage.jsx | 138 + frontend/src/pages/HomePage.jsx | 54 + frontend/src/pages/LoginPage.jsx | 89 + frontend/src/pages/NotFoundPage.jsx | 15 + frontend/src/pages/RegisterPage.jsx | 108 + frontend/src/pages/TicketPage.jsx | 77 + frontend/src/services/api.js | 61 + frontend/src/services/auth.js | 45 + frontend/src/services/auth.test.js | 44 + frontend/src/services/events.js | 155 + frontend/src/services/events.test.js | 105 + frontend/src/services/orders.js | 49 + frontend/src/services/queryCache.js | 32 + frontend/src/services/tickets.js | 19 + frontend/src/services/token.js | 31 + frontend/src/services/venues.js | 22 + frontend/src/styles/index.css | 66 + frontend/src/utils/.gitkeep | 0 frontend/src/utils/cn.js | 8 + frontend/src/utils/format.js | 61 + frontend/src/utils/mediaUrl.js | 28 + frontend/src/utils/mediaUrl.test.js | 22 + frontend/src/utils/motion.js | 6 + .../hero-atmosphere/HeroAtmosphereLayer.jsx | 43 + .../src/webgl/hero-atmosphere/eligibility.js | 34 + .../webgl/hero-atmosphere/eligibility.test.js | 55 + frontend/src/webgl/hero-atmosphere/index.js | 2 + .../hero-atmosphere/runHeroAtmosphere.js | 169 + .../useHeroAtmosphereEligible.js | 28 + frontend/tailwind.config.js | 115 + frontend/vite.config.js | 45 + scripts/dev-backend.ps1 | 16 + scripts/dev-backend.sh | 16 + scripts/dev-frontend.ps1 | 13 + scripts/dev-frontend.sh | 10 + scripts/migrate.ps1 | 4 + 246 files changed, 18660 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260404_0001_edition_tickets_meta.py create mode 100644 backend/alembic/versions/20260404_0002_outbound_clicks.py create mode 100644 backend/alembic/versions/20260404_0003_phase4_roles_subscribers.py create mode 100644 backend/alembic/versions/20260404_0004_platform_highape.py create mode 100644 backend/alembic/versions/20260404_0005_events_venues_catalog.py create mode 100644 backend/alembic/versions/20260404_0006_users.py create mode 100644 backend/alembic/versions/20260404_0007_orders_tickets.py create mode 100644 backend/alembic/versions/20260404_0008_payments.py create mode 100644 backend/alembic/versions/20260404_0009_digital_tickets.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/admin/router.py create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/events.py create mode 100644 backend/app/api/routes/health.py create mode 100644 backend/app/api/routes/orders.py create mode 100644 backend/app/api/routes/tickets.py create mode 100644 backend/app/api/routes/venues.py create mode 100644 backend/app/api/routes/webhooks.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/core/rate_limit.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/database.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/catalog.py create mode 100644 backend/app/models/entities.py create mode 100644 backend/app/models/enums.py create mode 100644 backend/app/models/orders.py create mode 100644 backend/app/models/payments.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/admin_users.py create mode 100644 backend/app/routers/analytics.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/public.py create mode 100644 backend/app/routers/share.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/admin.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/catalog.py create mode 100644 backend/app/schemas/domain.py create mode 100644 backend/app/schemas/health.py create mode 100644 backend/app/schemas/orders.py create mode 100644 backend/app/schemas/tickets.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/admin/__init__.py create mode 100644 backend/app/services/admin/catalog.py create mode 100644 backend/app/services/admin/dashboard.py create mode 100644 backend/app/services/admin/orders.py create mode 100644 backend/app/services/alerts.py create mode 100644 backend/app/services/calendar.py create mode 100644 backend/app/services/catalog.py create mode 100644 backend/app/services/editions.py create mode 100644 backend/app/services/email.py create mode 100644 backend/app/services/health.py create mode 100644 backend/app/services/media.py create mode 100644 backend/app/services/orders.py create mode 100644 backend/app/services/outbound.py create mode 100644 backend/app/services/payments/__init__.py create mode 100644 backend/app/services/payments/gateway.py create mode 100644 backend/app/services/payments/service.py create mode 100644 backend/app/services/qr.py create mode 100644 backend/app/services/share_preview.py create mode 100644 backend/app/services/tickets.py create mode 100644 backend/app/services/users.py create mode 100644 backend/app/storage.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/storage.py create mode 100644 backend/requirements.txt create mode 100644 backend/scripts/migrate.py create mode 100644 backend/scripts/seed.py create mode 100644 backend/scripts/seed_admin.py create mode 100644 backend/scripts/seed_catalog.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/helpers.py create mode 100644 backend/tests/test_admin_api.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_catalog_api.py create mode 100644 backend/tests/test_digital_tickets.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_orders.py create mode 100644 backend/tests/test_payments.py create mode 100644 backend/tests/test_security.py create mode 100644 backend/tests/test_security_hardening.py create mode 100644 docker-compose.yml create mode 100644 docs/API_SPEC.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATABASE.md create mode 100644 docs/DESIGN_SYSTEM.md create mode 100644 docs/PERFORMANCE.md create mode 100644 docs/PRD.md create mode 100644 docs/PROGRESS.md create mode 100644 docs/SECURITY.md create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/admin/AdminAuthContext.jsx create mode 100644 frontend/src/admin/AdminProtectedRoute.jsx create mode 100644 frontend/src/admin/AdminRoutes.jsx create mode 100644 frontend/src/admin/api.js create mode 100644 frontend/src/admin/components/AdminField.jsx create mode 100644 frontend/src/admin/components/AdminLayout.jsx create mode 100644 frontend/src/admin/components/AdminPageHeader.jsx create mode 100644 frontend/src/admin/components/AdminTable.jsx create mode 100644 frontend/src/admin/pages/AdminDashboardPage.jsx create mode 100644 frontend/src/admin/pages/AdminEventsPage.jsx create mode 100644 frontend/src/admin/pages/AdminLoginPage.jsx create mode 100644 frontend/src/admin/pages/AdminOrdersPage.jsx create mode 100644 frontend/src/admin/pages/AdminPaymentsPage.jsx create mode 100644 frontend/src/admin/pages/AdminStatsPage.jsx create mode 100644 frontend/src/admin/pages/AdminTicketTypesPage.jsx create mode 100644 frontend/src/admin/pages/AdminVenuesPage.jsx create mode 100644 frontend/src/admin/pages/AdminVerifyPage.jsx create mode 100644 frontend/src/admin/token.js create mode 100644 frontend/src/animations/gsap.js create mode 100644 frontend/src/animations/loadGsap.js create mode 100644 frontend/src/animations/useEventDetailAnimations.js create mode 100644 frontend/src/animations/useHomeScrollAnimations.js create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/auth/AuthFormLayout.jsx create mode 100644 frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/src/components/cards/EventCard.jsx create mode 100644 frontend/src/components/common/RequestState.jsx create mode 100644 frontend/src/components/common/RouteFallback.jsx create mode 100644 frontend/src/components/event/EventDescription.jsx create mode 100644 frontend/src/components/event/EventDetailGallery.jsx create mode 100644 frontend/src/components/event/EventDetailHeader.jsx create mode 100644 frontend/src/components/event/EventDetailHero.jsx create mode 100644 frontend/src/components/event/EventFaq.jsx create mode 100644 frontend/src/components/event/EventHighlights.jsx create mode 100644 frontend/src/components/event/EventMobileTicketBar.jsx create mode 100644 frontend/src/components/event/EventScheduleLocation.jsx create mode 100644 frontend/src/components/event/EventTicketCta.jsx create mode 100644 frontend/src/components/event/EventTicketTiers.jsx create mode 100644 frontend/src/components/event/EventVenueInfo.jsx create mode 100644 frontend/src/components/event/index.js create mode 100644 frontend/src/components/home/BrandStorySection.jsx create mode 100644 frontend/src/components/home/CategoriesSection.jsx create mode 100644 frontend/src/components/home/FeaturedEventSection.jsx create mode 100644 frontend/src/components/home/FinalCtaSection.jsx create mode 100644 frontend/src/components/home/GallerySection.jsx create mode 100644 frontend/src/components/home/HomeHero.jsx create mode 100644 frontend/src/components/home/MarketingFooter.jsx create mode 100644 frontend/src/components/home/UpcomingEventsSection.jsx create mode 100644 frontend/src/components/home/VenuesSection.jsx create mode 100644 frontend/src/components/index.js create mode 100644 frontend/src/components/layout/SectionSplit.jsx create mode 100644 frontend/src/components/navigation/MarketingNavbar.jsx create mode 100644 frontend/src/components/navigation/NavLink.jsx create mode 100644 frontend/src/components/navigation/Navbar.jsx create mode 100644 frontend/src/components/navigation/SiteFooter.jsx create mode 100644 frontend/src/components/tickets/DigitalTicket.jsx create mode 100644 frontend/src/components/typography/Eyebrow.jsx create mode 100644 frontend/src/components/typography/Heading.jsx create mode 100644 frontend/src/components/typography/Text.jsx create mode 100644 frontend/src/components/ui/Badge.jsx create mode 100644 frontend/src/components/ui/Button.jsx create mode 100644 frontend/src/components/ui/Card.jsx create mode 100644 frontend/src/components/ui/Container.jsx create mode 100644 frontend/src/components/ui/EmptyState.jsx create mode 100644 frontend/src/components/ui/Section.jsx create mode 100644 frontend/src/components/ui/Skeleton.jsx create mode 100644 frontend/src/components/ui/Spinner.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/data/eventsMock.js create mode 100644 frontend/src/data/homeMock.js create mode 100644 frontend/src/hooks/.gitkeep create mode 100644 frontend/src/hooks/useAsyncQuery.js create mode 100644 frontend/src/hooks/useBreakpoint.js create mode 100644 frontend/src/hooks/useCheckout.js create mode 100644 frontend/src/hooks/useDocumentTitle.js create mode 100644 frontend/src/hooks/useEvents.js create mode 100644 frontend/src/hooks/useVenues.js create mode 100644 frontend/src/layouts/AppLayout.jsx create mode 100644 frontend/src/layouts/MarketingLayout.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AccountPage.jsx create mode 100644 frontend/src/pages/DesignSystemPage.jsx create mode 100644 frontend/src/pages/EventDetailsPage.jsx create mode 100644 frontend/src/pages/HomePage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/NotFoundPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 frontend/src/pages/TicketPage.jsx create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/auth.js create mode 100644 frontend/src/services/auth.test.js create mode 100644 frontend/src/services/events.js create mode 100644 frontend/src/services/events.test.js create mode 100644 frontend/src/services/orders.js create mode 100644 frontend/src/services/queryCache.js create mode 100644 frontend/src/services/tickets.js create mode 100644 frontend/src/services/token.js create mode 100644 frontend/src/services/venues.js create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/utils/.gitkeep create mode 100644 frontend/src/utils/cn.js create mode 100644 frontend/src/utils/format.js create mode 100644 frontend/src/utils/mediaUrl.js create mode 100644 frontend/src/utils/mediaUrl.test.js create mode 100644 frontend/src/utils/motion.js create mode 100644 frontend/src/webgl/hero-atmosphere/HeroAtmosphereLayer.jsx create mode 100644 frontend/src/webgl/hero-atmosphere/eligibility.js create mode 100644 frontend/src/webgl/hero-atmosphere/eligibility.test.js create mode 100644 frontend/src/webgl/hero-atmosphere/index.js create mode 100644 frontend/src/webgl/hero-atmosphere/runHeroAtmosphere.js create mode 100644 frontend/src/webgl/hero-atmosphere/useHeroAtmosphereEligible.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 scripts/dev-backend.ps1 create mode 100644 scripts/dev-backend.sh create mode 100644 scripts/dev-frontend.ps1 create mode 100644 scripts/dev-frontend.sh create mode 100644 scripts/migrate.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4e21b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ +frontend/dist/ + +# Python +backend/.venv/ +backend/__pycache__/ +backend/**/__pycache__/ +backend/*.py[cod] +backend/.pytest_cache/ +backend/.mypy_cache/ +backend/.ruff_cache/ + +# Environment & secrets +.env +backend/.env +frontend/.env +frontend/.env.local +frontend/.env.*.local + +# Media & uploads +backend/uploads/ + +# Logs & OS +*.log +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/* +!.vscode/extensions.json + +# Build +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..379fbfd --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Desi Clans + +Premium Bengaluru-based platform for **Indian cultural events**—Holi, Garba, Dandiya, and related celebrations. + +--- + +## Stack + +| Layer | Technologies | +|-------|----------------| +| Frontend | React (JSX), Vite, Tailwind CSS 3, React Router, Axios, GSAP, Lucide React | +| Backend | FastAPI, Python, SQLAlchemy, Pydantic, Alembic, JWT, PostgreSQL | + +--- + +## Repository structure + +``` +DESI-CLANS/ +├── frontend/ # Vite + React foundation +├── backend/ # FastAPI foundation (+ routers for next phase) +├── docs/ # PRD, architecture, API, database, design system +├── scripts/ # Dev helpers (PowerShell + shell) +├── docker-compose.yml # Local PostgreSQL +└── README.md +``` + +--- + +## Quick start + +### 1. Database + +```bash +docker compose up -d db +``` + +Default URL matches `backend/.env.example`: + +`postgresql://desiclans:desiclans_dev@localhost:5432/desiclans` + +### 2. Backend + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux +pip install -r requirements.txt +cp .env.example .env # if .env does not exist +python scripts/migrate.py # optional until business routers are enabled +uvicorn app.main:app --reload --port 8000 +``` + +Or from repo root (Windows): `.\scripts\dev-backend.ps1` + +Health check: `http://localhost:8000/api/health` + +### 3. Frontend + +```bash +cd frontend +npm install +cp .env.example .env +npm run dev +``` + +Or: `.\scripts\dev-frontend.ps1` + +App: `http://localhost:5173` (proxies `/api` to the backend in dev) + +--- + +## Documentation + +| Doc | Purpose | +|-----|---------| +| [docs/PRD.md](./docs/PRD.md) | Product scope | +| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System design & phases | +| [docs/DESIGN_SYSTEM.md](./docs/DESIGN_SYSTEM.md) | UI tokens & components | +| [docs/API_SPEC.md](./docs/API_SPEC.md) | REST API | +| [docs/DATABASE.md](./docs/DATABASE.md) | Schema | +| [docs/PROGRESS.md](./docs/PROGRESS.md) | Implementation status | + +--- + +## Implementation status + +**Phase 1 foundation** (current): project scaffolding, env templates, dev scripts, health endpoint, frontend shell—**no business features wired yet**. + +See [docs/PROGRESS.md](./docs/PROGRESS.md) for the checklist. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..658e76d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,50 @@ +# Core +ENVIRONMENT=development +DATABASE_URL=postgresql://desiclans:desiclans_dev@localhost:5432/desiclans +# Use a long random value in production (32+ chars). Never commit real secrets. +SECRET_KEY=change-me-to-a-long-random-string-in-production +# Attendee JWT lifetime (minutes). Default 1440 = 24h. +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# Payments — mock (local) or razorpay (production) +PAYMENT_PROVIDER=mock +PAYMENT_MOCK_SECRET=dev-mock-payment-secret-change-me +ORDER_PAYMENT_TIMEOUT_MINUTES=15 +# RAZORPAY_KEY_ID= +# RAZORPAY_KEY_SECRET= +# RAZORPAY_WEBHOOK_SECRET= + +# Gate / scanner API key for POST /api/tickets/verify (required in production, min 16 chars) +# TICKET_VERIFY_API_KEY=generate-a-long-random-string-for-gate-scanners + +# CORS (comma-separated origins) +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Public URLs (SEO, share previews, calendar links) +PUBLIC_SITE_URL=http://localhost:5173 +PUBLIC_API_URL=http://localhost:8000 + +# Media +UPLOAD_DIR=uploads +STORAGE_BACKEND=local + +# Seed script defaults (development only) +ADMIN_EMAIL=admin@desiclans.in +ADMIN_PASSWORD=changeme + +# Optional SMTP alerts +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_USER= +# SMTP_PASSWORD= +# SMTP_FROM= +# SMTP_USE_TLS=true +# ALERT_NOTIFY_EMAIL= +# ALERT_CLICK_THRESHOLD=50 + +# Optional S3 (when STORAGE_BACKEND=s3) +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_REGION=ap-south-1 +# S3_BUCKET= +# S3_PUBLIC_BASE_URL= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..64148dc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev gcc && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini . +COPY scripts ./scripts + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 + +CMD ["sh", "-c", "python scripts/migrate.py && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3d2565d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,67 @@ +# Desi Clans — Backend + +FastAPI application with layered architecture, PostgreSQL, SQLAlchemy, Alembic, and JWT-ready configuration. + +## Layout + +``` +app/ +├── main.py # Application factory +├── core/ # Settings, dependencies, errors +├── db/ # SQLAlchemy engine, session, Base +├── models/ # ORM entities & enums +├── schemas/ # Pydantic request/response models +├── api/ # API routers +│ └── routes/ # Route modules (health, …) +├── services/ # Business logic +└── utils/ # Storage and helpers +``` + +Legacy route modules under `app/routers/` remain for a future mount phase. + +## Prerequisites + +- Python 3.11+ +- PostgreSQL 15+ (local or Docker) + +## Setup + +```bash +cd backend +python -m venv .venv +# Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env +``` + +Start Postgres (from repo root): + +```bash +docker compose up -d db +``` + +Run migrations and optional seed: + +```bash +python scripts/migrate.py +python scripts/seed_catalog.py +python scripts/seed.py +``` + +Start API: + +```bash +uvicorn app.main:app --reload --port 8000 +``` + +Health (includes DB check): [http://localhost:8000/api/health](http://localhost:8000/api/health) + +## Tests + +```bash +pytest tests/ +``` + +## Environment + +Copy `.env.example` to `.env`. Required: `DATABASE_URL`, `SECRET_KEY`. diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..fc8dd0d --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,67 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.db.base import Base +from app.models import ( # noqa: F401 + AdminUser, + AlertDispatch, + Event, + EventEdition, + EventMedia, + MediaAsset, + NewsletterSubscriber, + Order, + OrderItem, + OutboundClick, + PaymentWebhookEvent, + PlatformLink, + Ticket, + TicketType, + User, + Venue, +) + +config = context.config +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260404_0001_edition_tickets_meta.py b/backend/alembic/versions/20260404_0001_edition_tickets_meta.py new file mode 100644 index 0000000..d40572b --- /dev/null +++ b/backend/alembic/versions/20260404_0001_edition_tickets_meta.py @@ -0,0 +1,31 @@ +"""edition ticket urls and meta description + +Revision ID: 20260404_0001 +Revises: +Create Date: 2026-04-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS bookmyshow_url VARCHAR(512)") + op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS district_url VARCHAR(512)") + op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(512)") + op.execute("ALTER TABLE event_editions ADD COLUMN IF NOT EXISTS meta_description VARCHAR(320)") + + +def downgrade() -> None: + op.drop_column("event_editions", "meta_description") + op.drop_column("event_editions", "instagram_url") + op.drop_column("event_editions", "district_url") + op.drop_column("event_editions", "bookmyshow_url") diff --git a/backend/alembic/versions/20260404_0002_outbound_clicks.py b/backend/alembic/versions/20260404_0002_outbound_clicks.py new file mode 100644 index 0000000..6dda4a5 --- /dev/null +++ b/backend/alembic/versions/20260404_0002_outbound_clicks.py @@ -0,0 +1,39 @@ +"""outbound click analytics table + +Revision ID: 20260404_0002 +Revises: 20260404_0001 +Create Date: 2026-04-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0002" +down_revision: Union[str, None] = "20260404_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "outbound_clicks", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("link_kind", sa.String(length=64), nullable=False), + sa.Column("destination_url", sa.String(length=512), nullable=False), + sa.Column("edition_id", sa.Integer(), nullable=True), + sa.Column("referrer", sa.String(length=512), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["edition_id"], ["event_editions.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_outbound_clicks_link_kind", "outbound_clicks", ["link_kind"]) + op.create_index("ix_outbound_clicks_edition_id", "outbound_clicks", ["edition_id"]) + op.create_index("ix_outbound_clicks_created_at", "outbound_clicks", ["created_at"]) + + +def downgrade() -> None: + op.drop_table("outbound_clicks") diff --git a/backend/alembic/versions/20260404_0003_phase4_roles_subscribers.py b/backend/alembic/versions/20260404_0003_phase4_roles_subscribers.py new file mode 100644 index 0000000..8115bc2 --- /dev/null +++ b/backend/alembic/versions/20260404_0003_phase4_roles_subscribers.py @@ -0,0 +1,56 @@ +"""admin roles, subscribers, alert log + +Revision ID: 20260404_0003 +Revises: 20260404_0002 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0003" +down_revision: Union[str, None] = "20260404_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + DO $$ BEGIN + CREATE TYPE adminrole AS ENUM ('super_admin', 'editor'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """ + ) + op.execute( + "ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role adminrole NOT NULL DEFAULT 'super_admin'" + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS newsletter_subscribers ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + neighborhood VARCHAR(120), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS alert_dispatches ( + id SERIAL PRIMARY KEY, + alert_key VARCHAR(120) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS alert_dispatches") + op.execute("DROP TABLE IF EXISTS newsletter_subscribers") + op.execute("ALTER TABLE admin_users DROP COLUMN IF EXISTS role") diff --git a/backend/alembic/versions/20260404_0004_platform_highape.py b/backend/alembic/versions/20260404_0004_platform_highape.py new file mode 100644 index 0000000..cd2b6e1 --- /dev/null +++ b/backend/alembic/versions/20260404_0004_platform_highape.py @@ -0,0 +1,22 @@ +"""add highape platform kind + +Revision ID: 20260404_0004 +Revises: 20260404_0003 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260404_0004" +down_revision: Union[str, None] = "20260404_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TYPE platformkind ADD VALUE IF NOT EXISTS 'highape'") + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/20260404_0005_events_venues_catalog.py b/backend/alembic/versions/20260404_0005_events_venues_catalog.py new file mode 100644 index 0000000..d6b44b5 --- /dev/null +++ b/backend/alembic/versions/20260404_0005_events_venues_catalog.py @@ -0,0 +1,123 @@ +"""events venues event_media ticket_types catalog + +Revision ID: 20260404_0005 +Revises: 20260404_0004 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260404_0005" +down_revision: Union[str, None] = "20260404_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +event_category = postgresql.ENUM( + "holi", + "garba", + "dandiya", + "bhajan_clubbing", + "special", + name="eventtype", + create_type=False, +) +event_status = postgresql.ENUM( + "draft", + "published", + "cancelled", + name="eventstatus", + create_type=False, +) +ticket_type_status = postgresql.ENUM( + "active", + "sold_out", + "inactive", + name="tickettypestatus", + create_type=False, +) + + +def upgrade() -> None: + op.create_table( + "venues", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("name", sa.String(length=200), nullable=False), + sa.Column("area", sa.String(length=120), nullable=False), + sa.Column("city", sa.String(length=80), nullable=False, server_default="Bengaluru"), + sa.Column("address", sa.String(length=512), nullable=False), + sa.Column("map_url", sa.String(length=512), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_venues_slug", "venues", ["slug"], unique=True) + + event_category.create(op.get_bind(), checkfirst=True) + event_status.create(op.get_bind(), checkfirst=True) + ticket_type_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("slug", sa.String(length=200), nullable=False), + sa.Column("title", sa.String(length=240), nullable=False), + sa.Column("category", event_category, nullable=False), + sa.Column("status", event_status, nullable=False, server_default="draft"), + sa.Column("venue_id", sa.Integer(), sa.ForeignKey("venues.id", ondelete="RESTRICT"), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_featured", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("cover_image_url", sa.String(length=1024), nullable=True), + sa.Column("cover_image_alt", sa.String(length=300), nullable=True), + sa.Column("meta_description", sa.String(length=320), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_events_slug", "events", ["slug"], unique=True) + op.create_index("ix_events_category", "events", ["category"]) + op.create_index("ix_events_status", "events", ["status"]) + op.create_index("ix_events_venue_id", "events", ["venue_id"]) + op.create_index("ix_events_starts_at", "events", ["starts_at"]) + op.create_index("ix_events_status_starts_at", "events", ["status", "starts_at"]) + op.create_index("ix_events_category_status", "events", ["category", "status"]) + + op.create_table( + "event_media", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("image_url", sa.String(length=1024), nullable=False), + sa.Column("alt_text", sa.String(length=300), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_event_media_event_id", "event_media", ["event_id"]) + + op.create_table( + "ticket_types", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("description", sa.String(length=512), nullable=True), + sa.Column("price_paise", sa.Integer(), nullable=False), + sa.Column("quantity_total", sa.Integer(), nullable=False), + sa.Column("quantity_sold", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", ticket_type_status, nullable=False, server_default="active"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_ticket_types_event_id", "ticket_types", ["event_id"]) + + +def downgrade() -> None: + op.drop_table("ticket_types") + op.drop_table("event_media") + op.drop_table("events") + op.drop_table("venues") + ticket_type_status.drop(op.get_bind(), checkfirst=True) + event_status.drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/20260404_0006_users.py b/backend/alembic/versions/20260404_0006_users.py new file mode 100644 index 0000000..b1f4b59 --- /dev/null +++ b/backend/alembic/versions/20260404_0006_users.py @@ -0,0 +1,34 @@ +"""attendee users table + +Revision ID: 20260404_0006 +Revises: 20260404_0005 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0006" +down_revision: Union[str, None] = "20260404_0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("full_name", sa.String(length=120), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/backend/alembic/versions/20260404_0007_orders_tickets.py b/backend/alembic/versions/20260404_0007_orders_tickets.py new file mode 100644 index 0000000..0ded942 --- /dev/null +++ b/backend/alembic/versions/20260404_0007_orders_tickets.py @@ -0,0 +1,129 @@ +"""orders order_items tickets + +Revision ID: 20260404_0007 +Revises: 20260404_0006 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260404_0007" +down_revision: Union[str, None] = "20260404_0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +order_status = postgresql.ENUM( + "pending", + "paid", + "cancelled", + "failed", + name="orderstatus", + create_type=False, +) +ticket_status = postgresql.ENUM( + "valid", + "cancelled", + "redeemed", + name="ticketstatus", + create_type=False, +) + + +def upgrade() -> None: + order_status.create(op.get_bind(), checkfirst=True) + ticket_status.create(op.get_bind(), checkfirst=True) + + op.create_table( + "orders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=False), + sa.Column("status", order_status, nullable=False, server_default="pending"), + sa.Column("total_paise", sa.Integer(), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=False, server_default="INR"), + sa.Column("payment_reference", sa.String(length=64), nullable=True), + sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_orders_event_id"), "orders", ["event_id"], unique=False) + op.create_index(op.f("ix_orders_status"), "orders", ["status"], unique=False) + op.create_index(op.f("ix_orders_user_id"), "orders", ["user_id"], unique=False) + + op.create_table( + "order_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("order_id", sa.Integer(), nullable=False), + sa.Column("ticket_type_id", sa.Integer(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("unit_price_paise", sa.Integer(), nullable=False), + sa.Column("line_total_paise", sa.Integer(), nullable=False), + sa.CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"), + sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["ticket_type_id"], ["ticket_types.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_order_items_order_id"), "order_items", ["order_id"], unique=False) + op.create_index(op.f("ix_order_items_ticket_type_id"), "order_items", ["ticket_type_id"], unique=False) + + op.create_table( + "tickets", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=32), nullable=False), + sa.Column("order_id", sa.Integer(), nullable=False), + sa.Column("order_item_id", sa.Integer(), nullable=False), + sa.Column("ticket_type_id", sa.Integer(), nullable=False), + sa.Column("event_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("status", ticket_status, nullable=False, server_default="valid"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["order_item_id"], ["order_items.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["ticket_type_id"], ["ticket_types.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code"), + ) + op.create_index(op.f("ix_tickets_code"), "tickets", ["code"], unique=True) + op.create_index(op.f("ix_tickets_event_id"), "tickets", ["event_id"], unique=False) + op.create_index(op.f("ix_tickets_order_id"), "tickets", ["order_id"], unique=False) + op.create_index(op.f("ix_tickets_order_item_id"), "tickets", ["order_item_id"], unique=False) + op.create_index(op.f("ix_tickets_status"), "tickets", ["status"], unique=False) + op.create_index(op.f("ix_tickets_ticket_type_id"), "tickets", ["ticket_type_id"], unique=False) + op.create_index(op.f("ix_tickets_user_id"), "tickets", ["user_id"], unique=False) + op.create_index("ix_tickets_user_status", "tickets", ["user_id", "status"], unique=False) + + op.create_check_constraint( + "ck_ticket_types_sold_not_exceed_total", + "ticket_types", + "quantity_sold <= quantity_total", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_ticket_types_sold_not_exceed_total", "ticket_types", type_="check") + op.drop_index("ix_tickets_user_status", table_name="tickets") + op.drop_index(op.f("ix_tickets_user_id"), table_name="tickets") + op.drop_index(op.f("ix_tickets_ticket_type_id"), table_name="tickets") + op.drop_index(op.f("ix_tickets_status"), table_name="tickets") + op.drop_index(op.f("ix_tickets_order_item_id"), table_name="tickets") + op.drop_index(op.f("ix_tickets_order_id"), table_name="tickets") + op.drop_index(op.f("ix_tickets_event_id"), table_name="tickets") + op.drop_index(op.f("ix_tickets_code"), table_name="tickets") + op.drop_table("tickets") + op.drop_index(op.f("ix_order_items_ticket_type_id"), table_name="order_items") + op.drop_index(op.f("ix_order_items_order_id"), table_name="order_items") + op.drop_table("order_items") + op.drop_index(op.f("ix_orders_user_id"), table_name="orders") + op.drop_index(op.f("ix_orders_status"), table_name="orders") + op.drop_index(op.f("ix_orders_event_id"), table_name="orders") + op.drop_table("orders") + ticket_status.drop(op.get_bind(), checkfirst=True) + order_status.drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/20260404_0008_payments.py b/backend/alembic/versions/20260404_0008_payments.py new file mode 100644 index 0000000..d66a6ee --- /dev/null +++ b/backend/alembic/versions/20260404_0008_payments.py @@ -0,0 +1,73 @@ +"""payment fields and webhook idempotency + +Revision ID: 20260404_0008 +Revises: 20260404_0007 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0008" +down_revision: Union[str, None] = "20260404_0007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "orders", + "payment_reference", + existing_type=sa.String(length=64), + type_=sa.String(length=128), + existing_nullable=True, + ) + op.add_column("orders", sa.Column("provider_order_id", sa.String(length=64), nullable=True)) + op.add_column("orders", sa.Column("provider_payment_id", sa.String(length=64), nullable=True)) + op.add_column("orders", sa.Column("idempotency_key", sa.String(length=64), nullable=True)) + op.add_column("orders", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f("ix_orders_provider_order_id"), "orders", ["provider_order_id"], unique=True) + op.create_index(op.f("ix_orders_provider_payment_id"), "orders", ["provider_payment_id"], unique=False) + op.create_index(op.f("ix_orders_idempotency_key"), "orders", ["idempotency_key"], unique=True) + op.create_index(op.f("ix_orders_expires_at"), "orders", ["expires_at"], unique=False) + + op.create_table( + "payment_webhook_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("provider_event_id", sa.String(length=128), nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("payload", sa.Text(), nullable=False), + sa.Column("processed_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("provider_event_id"), + ) + op.create_index(op.f("ix_payment_webhook_events_provider"), "payment_webhook_events", ["provider"], unique=False) + op.create_index( + op.f("ix_payment_webhook_events_provider_event_id"), + "payment_webhook_events", + ["provider_event_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_payment_webhook_events_provider_event_id"), table_name="payment_webhook_events") + op.drop_index(op.f("ix_payment_webhook_events_provider"), table_name="payment_webhook_events") + op.drop_table("payment_webhook_events") + op.drop_index(op.f("ix_orders_expires_at"), table_name="orders") + op.drop_index(op.f("ix_orders_idempotency_key"), table_name="orders") + op.drop_index(op.f("ix_orders_provider_payment_id"), table_name="orders") + op.drop_index(op.f("ix_orders_provider_order_id"), table_name="orders") + op.drop_column("orders", "expires_at") + op.drop_column("orders", "idempotency_key") + op.drop_column("orders", "provider_payment_id") + op.drop_column("orders", "provider_order_id") + op.alter_column( + "orders", + "payment_reference", + existing_type=sa.String(length=128), + type_=sa.String(length=64), + existing_nullable=True, + ) diff --git a/backend/alembic/versions/20260404_0009_digital_tickets.py b/backend/alembic/versions/20260404_0009_digital_tickets.py new file mode 100644 index 0000000..0dff955 --- /dev/null +++ b/backend/alembic/versions/20260404_0009_digital_tickets.py @@ -0,0 +1,42 @@ +"""digital ticket qr tokens and used status + +Revision ID: 20260404_0009 +Revises: 20260404_0008 +""" + +import secrets +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260404_0009" +down_revision: Union[str, None] = "20260404_0008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TYPE ticketstatus RENAME VALUE 'redeemed' TO 'used'") + + op.add_column("tickets", sa.Column("qr_token", sa.String(length=64), nullable=True)) + op.add_column("tickets", sa.Column("used_at", sa.DateTime(timezone=True), nullable=True)) + + connection = op.get_bind() + rows = connection.execute(sa.text("SELECT id FROM tickets WHERE qr_token IS NULL")).fetchall() + for row in rows: + token = secrets.token_urlsafe(32) + connection.execute( + sa.text("UPDATE tickets SET qr_token = :token WHERE id = :id"), + {"token": token, "id": row.id}, + ) + + op.alter_column("tickets", "qr_token", nullable=False) + op.create_index(op.f("ix_tickets_qr_token"), "tickets", ["qr_token"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_tickets_qr_token"), table_name="tickets") + op.drop_column("tickets", "used_at") + op.drop_column("tickets", "qr_token") + op.execute("ALTER TYPE ticketstatus RENAME VALUE 'used' TO 'redeemed'") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..0dfc3c0 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,3 @@ +from app.api.router import api_router + +__all__ = ["api_router"] diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..e786b98 --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from app.api.routes import auth, events, health, orders, tickets, venues, webhooks +from app.api.routes.admin.router import router as admin_router + +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(admin_router) +api_router.include_router(auth.router) +api_router.include_router(webhooks.router) +api_router.include_router(events.router) +api_router.include_router(orders.router) +api_router.include_router(tickets.router) +api_router.include_router(venues.router) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/admin/router.py b/backend/app/api/routes/admin/router.py new file mode 100644 index 0000000..a37119a --- /dev/null +++ b/backend/app/api/routes/admin/router.py @@ -0,0 +1,157 @@ +from fastapi import APIRouter, Header, HTTPException, Query, Request, status + +from app.core.deps import DbSession, EditorAdmin, SettingsDep +from app.schemas.admin import ( + AdminLoginRequest, + AdminToken, + AdminUserPublic, + DashboardMetrics, + EventAdmin, + EventCreate, + EventStatsRow, + EventUpdate, + OrderAdmin, + PaymentAdmin, + TicketTypeAdmin, + TicketTypeCreate, + TicketTypeUpdate, + VenueAdmin, + VenueCreate, + VenueUpdate, +) +from app.schemas.tickets import TicketVerifyRequest, TicketVerifyResponse +from app.core.config import get_settings +from app.core.rate_limit import enforce_rate_limit +from app.core.security import create_admin_access_token, verify_password +from app.models.entities import AdminUser +from app.models.enums import OrderStatus +from app.services.admin.catalog import ( + create_event, + create_ticket_type, + create_venue, + list_events_admin, + list_ticket_types, + list_venues_admin, + ticket_type_to_admin, + update_event, + update_ticket_type, + update_venue, +) +from app.services.admin.dashboard import get_dashboard_metrics, get_event_statistics +from app.services.admin.orders import get_order_admin, list_orders_admin, list_payments_admin +from app.services.tickets import assert_checkin_authorized, verify_ticket_at_gate + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.post("/auth/login", response_model=AdminToken) +def admin_login(body: AdminLoginRequest, request: Request, db: DbSession) -> AdminToken: + enforce_rate_limit(request, scope="admin_login", limit=20, window_seconds=60) + admin = db.query(AdminUser).filter(AdminUser.email == body.email.strip().lower()).first() + if not admin or not verify_password(body.password, admin.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + token = create_admin_access_token(admin.id, get_settings()) + return AdminToken(access_token=token) + + +@router.get("/auth/me", response_model=AdminUserPublic) +def admin_me(admin: EditorAdmin) -> AdminUser: + return admin + + +@router.get("/dashboard/metrics", response_model=DashboardMetrics) +def dashboard_metrics(db: DbSession, _: EditorAdmin) -> DashboardMetrics: + return get_dashboard_metrics(db) + + +@router.get("/stats/events", response_model=list[EventStatsRow]) +def event_statistics(db: DbSession, _: EditorAdmin) -> list[EventStatsRow]: + return get_event_statistics(db) + + +@router.get("/venues", response_model=list[VenueAdmin]) +def venues_list(db: DbSession, _: EditorAdmin) -> list[VenueAdmin]: + return [VenueAdmin.model_validate(v) for v in list_venues_admin(db)] + + +@router.post("/venues", response_model=VenueAdmin, status_code=201) +def venues_create(body: VenueCreate, db: DbSession, _: EditorAdmin) -> VenueAdmin: + return VenueAdmin.model_validate(create_venue(db, body)) + + +@router.patch("/venues/{venue_id}", response_model=VenueAdmin) +def venues_update(venue_id: int, body: VenueUpdate, db: DbSession, _: EditorAdmin) -> VenueAdmin: + return VenueAdmin.model_validate(update_venue(db, venue_id, body)) + + +@router.get("/events", response_model=list[EventAdmin]) +def events_list(db: DbSession, _: EditorAdmin) -> list[EventAdmin]: + return [EventAdmin.model_validate(e) for e in list_events_admin(db)] + + +@router.post("/events", response_model=EventAdmin, status_code=201) +def events_create(body: EventCreate, db: DbSession, _: EditorAdmin) -> EventAdmin: + return EventAdmin.model_validate(create_event(db, body)) + + +@router.patch("/events/{event_id}", response_model=EventAdmin) +def events_update(event_id: int, body: EventUpdate, db: DbSession, _: EditorAdmin) -> EventAdmin: + return EventAdmin.model_validate(update_event(db, event_id, body)) + + +@router.get("/ticket-types", response_model=list[TicketTypeAdmin]) +def ticket_types_list( + db: DbSession, + _: EditorAdmin, + event_id: int | None = Query(default=None), +) -> list[TicketTypeAdmin]: + return [TicketTypeAdmin.model_validate(row) for row in list_ticket_types(db, event_id)] + + +@router.post("/ticket-types", response_model=TicketTypeAdmin, status_code=201) +def ticket_types_create(body: TicketTypeCreate, db: DbSession, _: EditorAdmin) -> TicketTypeAdmin: + tier = create_ticket_type(db, body) + return TicketTypeAdmin.model_validate(ticket_type_to_admin(tier)) + + +@router.patch("/ticket-types/{tier_id}", response_model=TicketTypeAdmin) +def ticket_types_update( + tier_id: int, + body: TicketTypeUpdate, + db: DbSession, + _: EditorAdmin, +) -> TicketTypeAdmin: + tier = update_ticket_type(db, tier_id, body) + return TicketTypeAdmin.model_validate(ticket_type_to_admin(tier)) + + +@router.get("/orders", response_model=list[OrderAdmin]) +def orders_list( + db: DbSession, + _: EditorAdmin, + status: OrderStatus | None = Query(default=None), +) -> list[OrderAdmin]: + return [OrderAdmin.model_validate(o) for o in list_orders_admin(db, status)] + + +@router.get("/orders/{order_id}", response_model=OrderAdmin) +def orders_detail(order_id: int, db: DbSession, _: EditorAdmin) -> OrderAdmin: + return OrderAdmin.model_validate(get_order_admin(db, order_id)) + + +@router.get("/payments", response_model=list[PaymentAdmin]) +def payments_list(db: DbSession, _: EditorAdmin) -> list[PaymentAdmin]: + return [PaymentAdmin.model_validate(row) for row in list_payments_admin(db)] + + +@router.post("/tickets/verify", response_model=TicketVerifyResponse) +def admin_verify_ticket( + body: TicketVerifyRequest, + db: DbSession, + _: EditorAdmin, + settings: SettingsDep, + x_check_in_key: str | None = Header(default=None, alias="X-Check-In-Key"), +) -> TicketVerifyResponse: + assert_checkin_authorized(settings, x_check_in_key) + result = verify_ticket_at_gate(db, body.qr_token.strip()) + return TicketVerifyResponse.model_validate(result) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..ead3d66 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, HTTPException, Request, status + +from app.core.deps import CurrentUser, DbSession, SettingsDep +from app.core.rate_limit import enforce_rate_limit +from app.core.security import create_user_access_token +from app.models.user import User +from app.schemas.auth import LoginRequest, RegisterRequest, Token, UserPublic +from app.services.users import EmailAlreadyRegisteredError, authenticate_user, register_user + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED) +def register(body: RegisterRequest, request: Request, db: DbSession, settings: SettingsDep) -> Token: + enforce_rate_limit(request, scope="auth_register", limit=10, window_seconds=60) + try: + user = register_user(db, body) + except EmailAlreadyRegisteredError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An account with this email already exists", + ) + token = create_user_access_token(user.id, settings) + return Token(access_token=token) + + +@router.post("/login", response_model=Token) +def login(body: LoginRequest, request: Request, db: DbSession, settings: SettingsDep) -> Token: + enforce_rate_limit(request, scope="auth_login", limit=20, window_seconds=60) + user = authenticate_user(db, body.email, body.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + token = create_user_access_token(user.id, settings) + return Token(access_token=token) + + +@router.get("/me", response_model=UserPublic) +def me(user: CurrentUser) -> User: + return user diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py new file mode 100644 index 0000000..98a00aa --- /dev/null +++ b/backend/app/api/routes/events.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Query + +from app.core.deps import DbSession +from app.models.enums import EventStatus, EventType +from app.schemas.catalog import EventDetail, EventSummary +from app.schemas.orders import EventTicketTypePublic +from app.services.catalog import event_to_detail, event_to_summary, get_event_by_slug, list_events +from app.services.orders import list_event_ticket_types + +router = APIRouter(prefix="/events", tags=["events"]) + + +@router.get("", response_model=list[EventSummary]) +def get_events( + db: DbSession, + category: EventType | None = Query(default=None), + status: EventStatus | None = Query(default=EventStatus.published), + upcoming: bool = Query(default=True), +) -> list[EventSummary]: + rows = list_events(db, category=category, status=status, upcoming=upcoming) + return [event_to_summary(row) for row in rows] + + +@router.get("/{event_id:int}/tickets", response_model=list[EventTicketTypePublic]) +def get_event_tickets(event_id: int, db: DbSession) -> list[EventTicketTypePublic]: + rows = list_event_ticket_types(db, event_id) + return [EventTicketTypePublic.model_validate(row) for row in rows] + + +@router.get("/{slug}", response_model=EventDetail) +def get_event(slug: str, db: DbSession) -> EventDetail: + event = get_event_by_slug(db, slug=slug) + return event_to_detail(event) diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py new file mode 100644 index 0000000..70f674c --- /dev/null +++ b/backend/app/api/routes/health.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, status +from fastapi.responses import JSONResponse + +from app.core.deps import DbSession +from app.schemas.health import HealthResponse +from app.services.health import check_database + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def health_check(db: DbSession) -> HealthResponse | JSONResponse: + connected = check_database(db) + payload = HealthResponse( + status="ok" if connected else "degraded", + service="desi-clans-api", + database="connected" if connected else "unavailable", + ) + if not connected: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=payload.model_dump(), + ) + return payload diff --git a/backend/app/api/routes/orders.py b/backend/app/api/routes/orders.py new file mode 100644 index 0000000..4f38c0c --- /dev/null +++ b/backend/app/api/routes/orders.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Header, HTTPException, Request + +from app.core.deps import CurrentUser, DbSession +from app.core.rate_limit import enforce_rate_limit +from app.schemas.orders import OrderCheckoutResponse, OrderCreate, OrderPublic, PaymentVerifyRequest +from app.services.orders import cancel_pending_order, create_order, get_order_for_user +from app.core.config import get_settings +from app.services.payments.service import ( + build_checkout_payload, + mock_complete_pending_order, + verify_checkout_and_confirm, +) + +router = APIRouter(prefix="/orders", tags=["orders"]) + + +@router.post("", response_model=OrderCheckoutResponse, status_code=201) +def post_order( + body: OrderCreate, + db: DbSession, + user: CurrentUser, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), +) -> OrderCheckoutResponse: + order = create_order(db, user, body, idempotency_key=idempotency_key) + payment = build_checkout_payload(order) + return OrderCheckoutResponse( + **OrderPublic.model_validate(order).model_dump(), + payment=payment, + ) + + +@router.get("/{order_id}", response_model=OrderPublic) +def get_order(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic: + order = get_order_for_user(db, order_id, user.id) + return OrderPublic.model_validate(order) + + +@router.post("/{order_id}/payment/verify", response_model=OrderPublic) +def verify_order_payment( + order_id: int, + body: PaymentVerifyRequest, + request: Request, + db: DbSession, + user: CurrentUser, +) -> OrderPublic: + enforce_rate_limit(request, scope="payment_verify", limit=30, window_seconds=60) + order = get_order_for_user(db, order_id, user.id) + order = verify_checkout_and_confirm( + db, + order=order, + provider_order_id=body.provider_order_id, + provider_payment_id=body.provider_payment_id, + signature=body.signature, + ) + return OrderPublic.model_validate(order) + + +@router.post("/{order_id}/cancel", response_model=OrderPublic) +def cancel_order(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic: + order = get_order_for_user(db, order_id, user.id) + order = cancel_pending_order(db, order) + return OrderPublic.model_validate(order) + + +@router.post("/{order_id}/payment/mock-complete", response_model=OrderPublic) +def dev_mock_complete_payment(order_id: int, db: DbSession, user: CurrentUser) -> OrderPublic: + settings = get_settings() + if settings.environment.lower() != "development" or settings.payment_provider.lower() != "mock": + raise HTTPException(status_code=404, detail="Not found") + order = get_order_for_user(db, order_id, user.id) + order = mock_complete_pending_order(db, order) + return OrderPublic.model_validate(order) diff --git a/backend/app/api/routes/tickets.py b/backend/app/api/routes/tickets.py new file mode 100644 index 0000000..e381fd0 --- /dev/null +++ b/backend/app/api/routes/tickets.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Header, Request + +from app.core.config import get_settings +from app.core.deps import CurrentUser, DbSession +from app.core.rate_limit import enforce_rate_limit +from app.schemas.orders import TicketPublic +from app.schemas.tickets import TicketDetailPublic, TicketVerifyRequest, TicketVerifyResponse +from app.services.orders import get_ticket_for_user, list_user_tickets, ticket_to_public +from app.services.tickets import assert_checkin_authorized, get_ticket_detail_for_user, verify_ticket_at_gate + +router = APIRouter(prefix="/tickets", tags=["tickets"]) + + +@router.get("/my", response_model=list[TicketPublic]) +def my_tickets(db: DbSession, user: CurrentUser) -> list[TicketPublic]: + rows = list_user_tickets(db, user.id) + return [TicketPublic.model_validate(ticket_to_public(row)) for row in rows] + + +@router.post("/verify", response_model=TicketVerifyResponse) +def verify_ticket( + body: TicketVerifyRequest, + request: Request, + db: DbSession, + x_check_in_key: str | None = Header(default=None, alias="X-Check-In-Key"), +) -> TicketVerifyResponse: + enforce_rate_limit(request, scope="ticket_verify", limit=60, window_seconds=60) + settings = get_settings() + assert_checkin_authorized(settings, x_check_in_key) + result = verify_ticket_at_gate(db, body.qr_token.strip()) + return TicketVerifyResponse.model_validate(result) + + +@router.get("/{ticket_id}", response_model=TicketDetailPublic) +def get_ticket(ticket_id: int, db: DbSession, user: CurrentUser) -> TicketDetailPublic: + detail = get_ticket_detail_for_user(db, ticket_id, user.id) + return TicketDetailPublic.model_validate(detail) diff --git a/backend/app/api/routes/venues.py b/backend/app/api/routes/venues.py new file mode 100644 index 0000000..5155316 --- /dev/null +++ b/backend/app/api/routes/venues.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.core.deps import DbSession +from app.schemas.catalog import VenuePublic +from app.services.catalog import get_venue_by_id, list_venues + +router = APIRouter(prefix="/venues", tags=["venues"]) + + +@router.get("", response_model=list[VenuePublic]) +def get_venues(db: DbSession) -> list[VenuePublic]: + return [VenuePublic.model_validate(venue) for venue in list_venues(db)] + + +@router.get("/{venue_id}", response_model=VenuePublic) +def get_venue(venue_id: int, db: DbSession) -> VenuePublic: + venue = get_venue_by_id(db, venue_id=venue_id) + return VenuePublic.model_validate(venue) diff --git a/backend/app/api/routes/webhooks.py b/backend/app/api/routes/webhooks.py new file mode 100644 index 0000000..bdfdf70 --- /dev/null +++ b/backend/app/api/routes/webhooks.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Header, HTTPException, Request + +from app.core.config import get_settings +from app.core.deps import DbSession +from app.services.payments.service import process_webhook + +router = APIRouter(prefix="/webhooks", tags=["webhooks"]) + + +@router.post("/razorpay") +async def razorpay_webhook( + request: Request, + db: DbSession, + x_razorpay_signature: str | None = Header(default=None, alias="X-Razorpay-Signature"), +) -> dict: + body = await request.body() + return process_webhook(db, body=body, signature=x_razorpay_signature) + + +@router.post("/mock") +async def mock_payment_webhook( + request: Request, + db: DbSession, + x_mock_signature: str | None = Header(default=None, alias="X-Mock-Signature"), +) -> dict: + """Mock provider webhook (development / tests only).""" + settings = get_settings() + if settings.environment.lower() != "development": + raise HTTPException(status_code=404, detail="Not found") + body = await request.body() + return process_webhook(db, body=body, signature=x_mock_signature) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..5228a61 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,56 @@ +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models import AdminRole, AdminUser + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer(auto_error=False) + +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + return jwt.encode({"sub": subject, "exp": expire}, settings.secret_key, algorithm=ALGORITHM) + + +def get_current_admin( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + db: Session = Depends(get_db), +) -> AdminUser: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + try: + payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=[ALGORITHM]) + email: str | None = payload.get("sub") + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + user = db.query(AdminUser).filter(AdminUser.email == email).first() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +def require_super_admin( + admin: AdminUser = Depends(get_current_admin), +) -> AdminUser: + if admin.role != AdminRole.super_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Super admin required") + return admin diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..87343f6 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,5 @@ +"""Backward-compatible settings import. Prefer `app.core.config`.""" + +from app.core.config import Settings, get_settings, settings + +__all__ = ["Settings", "get_settings", "settings"] diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..c3b38a2 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,5 @@ +from app.core.config import Settings, get_settings + +settings = get_settings() + +__all__ = ["Settings", "get_settings", "settings"] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..0c768a7 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,105 @@ +from functools import lru_cache + +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +_INSECURE_SECRET_KEYS = frozenset( + { + "change-me", + "change-me-to-a-long-random-string-in-production", + "changeme", + "secret", + } +) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + environment: str = "development" + database_url: str + secret_key: str + access_token_expire_minutes: int = 60 * 24 + + payment_provider: str = "mock" + payment_mock_secret: str = "dev-mock-payment-secret-change-me" + razorpay_key_id: str | None = None + razorpay_key_secret: str | None = None + razorpay_webhook_secret: str | None = None + order_payment_timeout_minutes: int = 15 + ticket_verify_api_key: str | None = None + + @field_validator("secret_key") + @classmethod + def secret_key_must_be_strong_in_production(cls, value: str, info) -> str: + environment = (info.data.get("environment") or "development").lower() + if environment == "production" and value.strip().lower() in _INSECURE_SECRET_KEYS: + raise ValueError("SECRET_KEY must be set to a long random value in production") + if environment == "production" and len(value) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters in production") + return value + + @field_validator("access_token_expire_minutes") + @classmethod + def token_ttl_reasonable(cls, value: int) -> int: + if value < 5 or value > 60 * 24 * 14: + raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES must be between 5 and 20160") + return value + + cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + + upload_dir: str = "uploads" + storage_backend: str = "local" + + public_site_url: str = "http://localhost:5173" + public_api_url: str = "http://localhost:8000" + + admin_email: str = "admin@desiclans.in" + admin_password: str = "changeme" + + smtp_host: str | None = None + smtp_port: int = 587 + smtp_user: str | None = None + smtp_password: str | None = None + smtp_from: str | None = None + smtp_use_tls: bool = True + alert_notify_email: str | None = None + alert_click_threshold: int = 50 + + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_region: str = "ap-south-1" + s3_bucket: str | None = None + s3_public_base_url: str | None = None + + @property + def cors_origin_list(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + @property + def smtp_configured(self) -> bool: + return bool(self.smtp_host and self.alert_notify_email) + + @model_validator(mode="after") + def production_hardening(self) -> "Settings": + if self.environment.lower() != "production": + return self + if self.payment_provider.lower() == "mock": + raise ValueError("PAYMENT_PROVIDER must be razorpay in production") + if not self.ticket_verify_api_key or len(self.ticket_verify_api_key) < 16: + raise ValueError("TICKET_VERIFY_API_KEY is required in production (min 16 characters)") + if self.admin_password.strip().lower() in {"changeme", "change-me", "password"}: + raise ValueError("ADMIN_PASSWORD must be changed from the default in production") + if not self.cors_origin_list: + raise ValueError("CORS_ORIGINS must list at least one origin in production") + if self.payment_provider.lower() == "razorpay": + if not self.razorpay_key_id or not self.razorpay_key_secret or not self.razorpay_webhook_secret: + raise ValueError( + "RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, and RAZORPAY_WEBHOOK_SECRET are required in production" + ) + return self + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..24e5bfb --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,76 @@ +from collections.abc import Callable, Generator +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.config import Settings, get_settings +from app.core.security import decode_admin_access_token, decode_user_access_token +from app.db.session import get_db as _get_db +from app.models.entities import AdminUser +from app.models.enums import AdminRole +from app.models.user import User + +_bearer = HTTPBearer(auto_error=False) + + +def get_db() -> Generator[Session, None, None]: + yield from _get_db() + + +SettingsDep = Annotated[Settings, Depends(get_settings)] +DbSession = Annotated[Session, Depends(get_db)] + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + db: DbSession, + settings: SettingsDep, +) -> User: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + user_id = decode_user_access_token(credentials.credentials, settings) + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") + user = db.query(User).filter(User.id == user_id).first() + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def get_current_admin( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + db: DbSession, + settings: SettingsDep, +) -> AdminUser: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + admin_id = decode_admin_access_token(credentials.credentials, settings) + if admin_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") + admin = db.query(AdminUser).filter(AdminUser.id == admin_id).first() + if not admin: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found") + return admin + + +CurrentAdmin = Annotated[AdminUser, Depends(get_current_admin)] + + +def require_admin_roles(*roles: AdminRole) -> Callable: + allowed = set(roles) + + def _checker(admin: CurrentAdmin) -> AdminUser: + if admin.role not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return admin + + return _checker + + +EditorAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.editor, AdminRole.super_admin))] +SuperAdmin = Annotated[AdminUser, Depends(require_admin_roles(AdminRole.super_admin))] diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..825e734 --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + + +class AppError(Exception): + """Application-level error mapped to an HTTP response.""" + + def __init__(self, message: str, status_code: int = 400) -> None: + self.message = message + self.status_code = status_code + super().__init__(message) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppError) + async def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) + + @app.exception_handler(Exception) + async def handle_unexpected_error(_request: Request, exc: Exception) -> JSONResponse: + if app.debug: + return JSONResponse(status_code=500, content={"detail": str(exc)}) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..f42a5ef --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,37 @@ +import threading +import time +from collections import defaultdict + +from fastapi import Request + +from app.core.errors import AppError + +_buckets: dict[str, list[float]] = defaultdict(list) +_lock = threading.Lock() + + +def _client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client and request.client.host: + return request.client.host + return "unknown" + + +def enforce_rate_limit(request: Request, *, scope: str, limit: int, window_seconds: int) -> None: + """In-memory sliding window limiter (per process). Use edge/WAF limits in production at scale.""" + import os + + if os.environ.get("DESI_CLANS_DISABLE_RATE_LIMIT", "").lower() in {"1", "true", "yes"}: + return + if limit <= 0: + return + key = f"{scope}:{_client_ip(request)}" + now = time.time() + with _lock: + window = _buckets[key] + window[:] = [stamp for stamp in window if now - stamp < window_seconds] + if len(window) >= limit: + raise AppError("Too many requests. Try again shortly.", status_code=429) + window.append(now) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e30dbef --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,72 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import Settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" +TOKEN_TYPE_USER = "user" +TOKEN_TYPE_ADMIN = "admin" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_user_access_token(user_id: int, settings: Settings) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = { + "sub": str(user_id), + "exp": expire, + "type": TOKEN_TYPE_USER, + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_user_access_token(token: str, settings: Settings) -> int | None: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError: + return None + if payload.get("type") != TOKEN_TYPE_USER: + return None + subject = payload.get("sub") + if not subject: + return None + try: + return int(subject) + except (TypeError, ValueError): + return None + + +def create_admin_access_token(admin_id: int, settings: Settings) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = { + "sub": str(admin_id), + "exp": expire, + "type": TOKEN_TYPE_ADMIN, + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_admin_access_token(token: str, settings: Settings) -> int | None: + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + except JWTError: + return None + if payload.get("type") != TOKEN_TYPE_ADMIN: + return None + subject = payload.get("sub") + if not subject: + return None + try: + return int(subject) + except (TypeError, ValueError): + return None diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..4448159 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,6 @@ +"""Backward-compatible database import. Prefer `app.db`.""" + +from app.db.base import Base +from app.db.session import SessionLocal, engine, get_db + +__all__ = ["Base", "SessionLocal", "engine", "get_db"] diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..a732344 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1,4 @@ +from app.db.base import Base +from app.db.session import SessionLocal, engine, get_db + +__all__ = ["Base", "SessionLocal", "engine", "get_db"] diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..f8d3738 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,23 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() + +engine = create_engine( + settings.database_url, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8c0be3b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.api.router import api_router +from app.core.config import get_settings +from app.core.errors import register_exception_handlers + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="Desi Clans API", + version="0.2.0", + debug=settings.environment.lower() == "development", + ) + + register_exception_handlers(app) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + if settings.storage_backend.lower() == "local": + upload_path = Path(settings.upload_dir) + upload_path.mkdir(parents=True, exist_ok=True) + app.mount("/media", StaticFiles(directory=str(upload_path)), name="media") + + if settings.environment.lower() == "production": + app.openapi_url = None + + app.include_router(api_router, prefix="/api") + + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e6383b3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,50 @@ +from app.db.base import Base +from app.models.catalog import Event, EventMedia, TicketType, Venue +from app.models.user import User +from app.models.entities import ( + AdminUser, + AlertDispatch, + EventEdition, + MediaAsset, + NewsletterSubscriber, + OutboundClick, + PlatformLink, +) +from app.models.enums import ( + AdminRole, + EventStatus, + EventType, + OrderStatus, + PlatformKind, + TicketStatus, + TicketTypeStatus, +) +from app.models.orders import Order, OrderItem, Ticket +from app.models.payments import PaymentWebhookEvent + +__all__ = [ + "Base", + "AdminRole", + "AdminUser", + "AlertDispatch", + "Event", + "EventEdition", + "EventMedia", + "EventStatus", + "EventType", + "MediaAsset", + "NewsletterSubscriber", + "Order", + "OrderItem", + "OrderStatus", + "OutboundClick", + "PaymentWebhookEvent", + "PlatformKind", + "PlatformLink", + "Ticket", + "TicketStatus", + "TicketType", + "TicketTypeStatus", + "User", + "Venue", +] diff --git a/backend/app/models/catalog.py b/backend/app/models/catalog.py new file mode 100644 index 0000000..a27a0d9 --- /dev/null +++ b/backend/app/models/catalog.py @@ -0,0 +1,115 @@ +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.enums import EventStatus, EventType, TicketTypeStatus + + +class Venue(Base): + __tablename__ = "venues" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + slug: Mapped[str] = mapped_column(String(120), unique=True, index=True) + name: Mapped[str] = mapped_column(String(200)) + area: Mapped[str] = mapped_column(String(120)) + city: Mapped[str] = mapped_column(String(80), default="Bengaluru") + address: Mapped[str] = mapped_column(String(512)) + map_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + events: Mapped[list["Event"]] = relationship("Event", back_populates="venue") + + +class Event(Base): + __tablename__ = "events" + __table_args__ = ( + Index("ix_events_status_starts_at", "status", "starts_at"), + Index("ix_events_category_status", "category", "status"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + slug: Mapped[str] = mapped_column(String(200), unique=True, index=True) + title: Mapped[str] = mapped_column(String(240)) + category: Mapped[EventType] = mapped_column(Enum(EventType), index=True) + status: Mapped[EventStatus] = mapped_column(Enum(EventStatus), default=EventStatus.draft, index=True) + venue_id: Mapped[int] = mapped_column(ForeignKey("venues.id", ondelete="RESTRICT"), index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_featured: Mapped[bool] = mapped_column(Boolean, default=False) + cover_image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + cover_image_alt: Mapped[str | None] = mapped_column(String(300), nullable=True) + meta_description: Mapped[str | None] = mapped_column(String(320), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + venue: Mapped["Venue"] = relationship("Venue", back_populates="events") + media: Mapped[list["EventMedia"]] = relationship( + "EventMedia", + back_populates="event", + cascade="all, delete-orphan", + order_by="EventMedia.sort_order", + ) + ticket_types: Mapped[list["TicketType"]] = relationship( + "TicketType", + back_populates="event", + cascade="all, delete-orphan", + order_by="TicketType.sort_order", + ) + + +class EventMedia(Base): + __tablename__ = "event_media" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + image_url: Mapped[str] = mapped_column(String(1024)) + alt_text: Mapped[str | None] = mapped_column(String(300), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + event: Mapped["Event"] = relationship("Event", back_populates="media") + + +class TicketType(Base): + __tablename__ = "ticket_types" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(120)) + description: Mapped[str | None] = mapped_column(String(512), nullable=True) + price_paise: Mapped[int] = mapped_column(Integer) + quantity_total: Mapped[int] = mapped_column(Integer) + quantity_sold: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[TicketTypeStatus] = mapped_column(Enum(TicketTypeStatus), default=TicketTypeStatus.active) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + event: Mapped["Event"] = relationship("Event", back_populates="ticket_types") diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py new file mode 100644 index 0000000..4b14255 --- /dev/null +++ b/backend/app/models/entities.py @@ -0,0 +1,105 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.enums import AdminRole, EventType, PlatformKind + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + role: Mapped[AdminRole] = mapped_column(Enum(AdminRole), default=AdminRole.super_admin) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class NewsletterSubscriber(Base): + __tablename__ = "newsletter_subscribers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class AlertDispatch(Base): + __tablename__ = "alert_dispatches" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + alert_key: Mapped[str] = mapped_column(String(120), unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class PlatformLink(Base): + __tablename__ = "platform_links" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + kind: Mapped[PlatformKind] = mapped_column(Enum(PlatformKind), index=True) + label: Mapped[str] = mapped_column(String(120)) + url: Mapped[str] = mapped_column(String(512)) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class EventEdition(Base): + __tablename__ = "event_editions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + slug: Mapped[str] = mapped_column(String(200), unique=True, index=True) + event_type: Mapped[EventType] = mapped_column(Enum(EventType), index=True) + city: Mapped[str] = mapped_column(String(80), default="Bengaluru") + venue_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + is_featured: Mapped[bool] = mapped_column(Boolean, default=False) + cover_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True) + bookmyshow_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + district_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + instagram_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + meta_description: Mapped[str | None] = mapped_column(String(320), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + media: Mapped[list["MediaAsset"]] = relationship( + "MediaAsset", + back_populates="edition", + cascade="all, delete-orphan", + order_by="MediaAsset.sort_order", + ) + + +class OutboundClick(Base): + __tablename__ = "outbound_clicks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + link_kind: Mapped[str] = mapped_column(String(64), index=True) + destination_url: Mapped[str] = mapped_column(String(512)) + edition_id: Mapped[int | None] = mapped_column( + ForeignKey("event_editions.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + referrer: Mapped[str | None] = mapped_column(String(512), nullable=True) + user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + +class MediaAsset(Base): + __tablename__ = "media_assets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + edition_id: Mapped[int] = mapped_column(ForeignKey("event_editions.id", ondelete="CASCADE"), index=True) + file_path: Mapped[str] = mapped_column(String(512)) + caption: Mapped[str | None] = mapped_column(String(300), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + is_published: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + edition: Mapped["EventEdition"] = relationship("EventEdition", back_populates="media") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py new file mode 100644 index 0000000..b796201 --- /dev/null +++ b/backend/app/models/enums.py @@ -0,0 +1,101 @@ +import enum + + + + + +class EventType(str, enum.Enum): + + holi = "holi" + + garba = "garba" + + dandiya = "dandiya" + + bhajan_clubbing = "bhajan_clubbing" + + special = "special" + + + + + +class PlatformKind(str, enum.Enum): + + bookmyshow = "bookmyshow" + + district = "district" + + instagram = "instagram" + + highape = "highape" + + youtube = "youtube" + + whatsapp = "whatsapp" + + website = "website" + + other = "other" + + + + + +class AdminRole(str, enum.Enum): + + super_admin = "super_admin" + + editor = "editor" + + + + + +class EventStatus(str, enum.Enum): + + draft = "draft" + + published = "published" + + cancelled = "cancelled" + + + + + +class TicketTypeStatus(str, enum.Enum): + + active = "active" + + sold_out = "sold_out" + + inactive = "inactive" + + + + + +class OrderStatus(str, enum.Enum): + + pending = "pending" + + paid = "paid" + + cancelled = "cancelled" + + failed = "failed" + + + + + +class TicketStatus(str, enum.Enum): + + valid = "valid" + + used = "used" + + cancelled = "cancelled" + + diff --git a/backend/app/models/orders.py b/backend/app/models/orders.py new file mode 100644 index 0000000..025bf18 --- /dev/null +++ b/backend/app/models/orders.py @@ -0,0 +1,89 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.enums import OrderStatus, TicketStatus + +if TYPE_CHECKING: + from app.models.catalog import Event, TicketType + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"), index=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="RESTRICT"), index=True) + status: Mapped[OrderStatus] = mapped_column(Enum(OrderStatus), default=OrderStatus.pending, index=True) + total_paise: Mapped[int] = mapped_column(Integer) + currency: Mapped[str] = mapped_column(String(3), default="INR") + payment_reference: Mapped[str | None] = mapped_column(String(128), nullable=True) + provider_order_id: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True, index=True) + provider_payment_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + idempotency_key: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True, index=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + items: Mapped[list["OrderItem"]] = relationship( + "OrderItem", + back_populates="order", + cascade="all, delete-orphan", + ) + tickets: Mapped[list["Ticket"]] = relationship( + "Ticket", + back_populates="order", + cascade="all, delete-orphan", + ) + event: Mapped["Event"] = relationship("Event") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), index=True) + ticket_type_id: Mapped[int] = mapped_column(ForeignKey("ticket_types.id", ondelete="RESTRICT"), index=True) + quantity: Mapped[int] = mapped_column(Integer) + unit_price_paise: Mapped[int] = mapped_column(Integer) + line_total_paise: Mapped[int] = mapped_column(Integer) + + order: Mapped["Order"] = relationship("Order", back_populates="items") + ticket_type: Mapped["TicketType"] = relationship("TicketType") + tickets: Mapped[list["Ticket"]] = relationship("Ticket", back_populates="order_item") + + +class Ticket(Base): + __tablename__ = "tickets" + __table_args__ = (Index("ix_tickets_user_status", "user_id", "status"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + code: Mapped[str] = mapped_column(String(32), unique=True, index=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"), index=True) + order_item_id: Mapped[int] = mapped_column(ForeignKey("order_items.id", ondelete="CASCADE"), index=True) + ticket_type_id: Mapped[int] = mapped_column(ForeignKey("ticket_types.id", ondelete="RESTRICT"), index=True) + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="RESTRICT"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"), index=True) + status: Mapped[TicketStatus] = mapped_column(Enum(TicketStatus), default=TicketStatus.valid, index=True) + qr_token: Mapped[str] = mapped_column(String(64), unique=True, index=True) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + order: Mapped["Order"] = relationship("Order", back_populates="tickets") + order_item: Mapped["OrderItem"] = relationship("OrderItem", back_populates="tickets") diff --git a/backend/app/models/payments.py b/backend/app/models/payments.py new file mode 100644 index 0000000..a42fb09 --- /dev/null +++ b/backend/app/models/payments.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PaymentWebhookEvent(Base): + """Idempotent record of processed payment provider webhook events.""" + + __tablename__ = "payment_webhook_events" + + id: Mapped[int] = mapped_column(primary_key=True) + provider: Mapped[str] = mapped_column(String(32), index=True) + provider_event_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + event_type: Mapped[str] = mapped_column(String(64)) + payload: Mapped[str] = mapped_column(Text) + processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..09d7839 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class User(Base): + """Attendee account (not admin).""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + full_name: Mapped[str | None] = mapped_column(String(120), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..5006f94 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,238 @@ +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from sqlalchemy.orm import Session, joinedload + +from app.auth import get_current_admin +from app.database import get_db +from app.models import AdminUser, EventEdition, MediaAsset, PlatformLink +from app.schemas import ( + EventEditionCreate, + EventEditionAdmin, + EventEditionUpdate, + MediaReorderItem, + PlatformLinkCreate, + PlatformLinkPublic, + PlatformLinkUpdate, +) +from app.services.editions import edition_to_admin, edition_to_public +from app.services.media import public_url_for_path, save_image + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +@router.get("/me") +def me(admin: AdminUser = Depends(get_current_admin)): + return {"email": admin.email, "role": admin.role.value} + + +# --- Platform links --- + + +@router.get("/links", response_model=list[PlatformLinkPublic]) +def list_links(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)): + rows = db.query(PlatformLink).order_by(PlatformLink.sort_order, PlatformLink.id).all() + return [PlatformLinkPublic.model_validate(r) for r in rows] + + +@router.post("/links", response_model=PlatformLinkPublic) +def create_link( + body: PlatformLinkCreate, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + row = PlatformLink(**body.model_dump()) + db.add(row) + db.commit() + db.refresh(row) + return PlatformLinkPublic.model_validate(row) + + +@router.patch("/links/{link_id}", response_model=PlatformLinkPublic) +def update_link( + link_id: int, + body: PlatformLinkUpdate, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first() + if not row: + raise HTTPException(status_code=404, detail="Link not found") + for k, v in body.model_dump(exclude_unset=True).items(): + setattr(row, k, v) + db.commit() + db.refresh(row) + return PlatformLinkPublic.model_validate(row) + + +@router.delete("/links/{link_id}") +def delete_link(link_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)): + row = db.query(PlatformLink).filter(PlatformLink.id == link_id).first() + if not row: + raise HTTPException(status_code=404, detail="Link not found") + db.delete(row) + db.commit() + return {"ok": True} + + +# --- Editions --- + + +@router.get("/editions", response_model=list[EventEditionAdmin]) +def admin_list_editions(db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)): + editions = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .order_by(EventEdition.id.desc()) + .all() + ) + return [edition_to_admin(e) for e in editions] + + +@router.post("/editions", response_model=EventEditionAdmin) +def create_edition( + body: EventEditionCreate, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + if db.query(EventEdition).filter(EventEdition.slug == body.slug).first(): + raise HTTPException(status_code=400, detail="Slug already exists") + edition = EventEdition(city="Bengaluru", **body.model_dump()) + db.add(edition) + db.commit() + db.refresh(edition) + edition = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.id == edition.id) + .one() + ) + return edition_to_admin(edition) + + +@router.patch("/editions/{edition_id}", response_model=EventEditionAdmin) +def update_edition( + edition_id: int, + body: EventEditionUpdate, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first() + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + data = body.model_dump(exclude_unset=True) + if "slug" in data and data["slug"] != edition.slug: + if db.query(EventEdition).filter(EventEdition.slug == data["slug"]).first(): + raise HTTPException(status_code=400, detail="Slug already exists") + for k, v in data.items(): + setattr(edition, k, v) + db.commit() + edition = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.id == edition_id) + .one() + ) + return edition_to_admin(edition) + + +@router.post("/editions/{edition_id}/cover") +async def upload_cover( + edition_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first() + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + try: + path = save_image(file) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + edition.cover_image_path = path + db.commit() + return {"cover_image_url": public_url_for_path(path)} + + +@router.post("/editions/{edition_id}/media") +async def upload_media( + edition_id: int, + file: UploadFile = File(...), + caption: str | None = Form(default=None), + sort_order: int = Form(default=0), + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + edition = db.query(EventEdition).filter(EventEdition.id == edition_id).first() + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + try: + path = save_image(file) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + asset = MediaAsset( + edition_id=edition_id, + file_path=path, + caption=caption, + sort_order=sort_order, + is_published=True, + ) + db.add(asset) + db.commit() + db.refresh(asset) + return { + "id": asset.id, + "url": public_url_for_path(path), + "caption": asset.caption, + "sort_order": asset.sort_order, + } + + +@router.patch("/media/{media_id}") +def update_media( + media_id: int, + caption: str | None = Form(default=None), + sort_order: int | None = Form(default=None), + is_published: bool | None = Form(default=None), + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first() + if not asset: + raise HTTPException(status_code=404, detail="Media not found") + if caption is not None: + asset.caption = caption + if sort_order is not None: + asset.sort_order = sort_order + if is_published is not None: + asset.is_published = is_published + db.commit() + return {"ok": True} + + +@router.post("/editions/{edition_id}/media/reorder") +def reorder_media( + edition_id: int, + items: list[MediaReorderItem], + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + for item in items: + asset = ( + db.query(MediaAsset) + .filter(MediaAsset.id == item.id, MediaAsset.edition_id == edition_id) + .first() + ) + if asset: + asset.sort_order = item.sort_order + db.commit() + return {"ok": True} + + +@router.delete("/media/{media_id}") +def delete_media(media_id: int, db: Session = Depends(get_db), _: AdminUser = Depends(get_current_admin)): + asset = db.query(MediaAsset).filter(MediaAsset.id == media_id).first() + if not asset: + raise HTTPException(status_code=404, detail="Media not found") + db.delete(asset) + db.commit() + return {"ok": True} diff --git a/backend/app/routers/admin_users.py b/backend/app/routers/admin_users.py new file mode 100644 index 0000000..ddeca83 --- /dev/null +++ b/backend/app/routers/admin_users.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.auth import hash_password, require_super_admin +from app.database import get_db +from app.models import AdminUser +from app.schemas import AdminUserCreate, AdminUserPublic, SubscriberPublic +from app.models import NewsletterSubscriber + +router = APIRouter(prefix="/api/admin", tags=["admin-users"]) + + +@router.get("/users", response_model=list[AdminUserPublic]) +def list_users(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)): + return db.query(AdminUser).order_by(AdminUser.id).all() + + +@router.post("/users", response_model=AdminUserPublic) +def create_user( + body: AdminUserCreate, + db: Session = Depends(get_db), + _: AdminUser = Depends(require_super_admin), +): + if db.query(AdminUser).filter(AdminUser.email == body.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + user = AdminUser( + email=body.email, + hashed_password=hash_password(body.password), + role=body.role, + ) + db.add(user) + db.commit() + db.refresh(user) + return AdminUserPublic.model_validate(user) + + +@router.get("/subscribers", response_model=list[SubscriberPublic]) +def list_subscribers(db: Session = Depends(get_db), _: AdminUser = Depends(require_super_admin)): + return ( + db.query(NewsletterSubscriber) + .order_by(NewsletterSubscriber.id.desc()) + .limit(500) + .all() + ) diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py new file mode 100644 index 0000000..3e73e14 --- /dev/null +++ b/backend/app/routers/analytics.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.auth import get_current_admin +from app.database import get_db +from app.models import AdminUser, OutboundClick +from app.schemas import ClickSummaryItem, OutboundClickPublic + +router = APIRouter(prefix="/api/admin/analytics", tags=["analytics"]) + + +@router.get("/clicks/summary", response_model=list[ClickSummaryItem]) +def click_summary( + days: int = 30, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + since = datetime.now(timezone.utc) - timedelta(days=max(1, min(days, 365))) + rows = ( + db.query(OutboundClick.link_kind, func.count(OutboundClick.id)) + .filter(OutboundClick.created_at >= since) + .group_by(OutboundClick.link_kind) + .order_by(func.count(OutboundClick.id).desc()) + .all() + ) + return [ClickSummaryItem(link_kind=kind, count=count) for kind, count in rows] + + +@router.get("/clicks/recent", response_model=list[OutboundClickPublic]) +def recent_clicks( + limit: int = 50, + db: Session = Depends(get_db), + _: AdminUser = Depends(get_current_admin), +): + limit = max(1, min(limit, 200)) + rows = db.query(OutboundClick).order_by(OutboundClick.id.desc()).limit(limit).all() + return [OutboundClickPublic.model_validate(r) for r in rows] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..30425c8 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.auth import create_access_token, verify_password +from app.database import get_db +from app.models import AdminUser +from app.schemas import LoginRequest, Token + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=Token) +def login(body: LoginRequest, db: Session = Depends(get_db)): + user = db.query(AdminUser).filter(AdminUser.email == body.email).first() + if not user or not verify_password(body.password, user.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + return Token(access_token=create_access_token(user.email)) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py new file mode 100644 index 0000000..a6ddf57 --- /dev/null +++ b/backend/app/routers/public.py @@ -0,0 +1,151 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from sqlalchemy.orm import Session, joinedload + +from app.config import settings +from app.database import get_db +from app.models import EventEdition, EventType, NewsletterSubscriber, OutboundClick, PlatformLink +from app.schemas import EventEditionPublic, PlatformLinkPublic, SiteConfigPublic, SubscribeRequest +from app.services.alerts import maybe_notify_click_spike +from app.services.calendar import build_ics +from app.services.editions import edition_to_public +from app.services.outbound import validate_outbound_url +from app.services.share_preview import render_share_html + +router = APIRouter(prefix="/api/public", tags=["public"]) + +TAGLINE = "Bengaluru's home for Holi, Garba, Dandiya & bhajan nights — across the city's best venues." + + +@router.get("/health") +def health(): + return {"status": "ok", "city": "Bengaluru"} + + +@router.get("/config", response_model=SiteConfigPublic) +def site_config(db: Session = Depends(get_db)): + links = ( + db.query(PlatformLink) + .filter(PlatformLink.is_active.is_(True)) + .order_by(PlatformLink.sort_order, PlatformLink.id) + .all() + ) + + editions = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.is_published.is_(True)) + .order_by(EventEdition.is_featured.desc(), EventEdition.sort_order, EventEdition.event_date.desc().nullslast()) + .all() + ) + + featured = next((e for e in editions if e.is_featured), editions[0] if editions else None) + recent = [edition_to_public(e) for e in editions[:6]] + + return SiteConfigPublic( + city="Bengaluru", + tagline=TAGLINE, + platform_links=[PlatformLinkPublic.model_validate(l) for l in links], + featured_edition=edition_to_public(featured) if featured else None, + recent_editions=recent, + ) + + +@router.get("/editions", response_model=list[EventEditionPublic]) +def list_editions( + db: Session = Depends(get_db), + event_type: EventType | None = Query(default=None), +): + q = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.is_published.is_(True)) + ) + if event_type is not None: + q = q.filter(EventEdition.event_type == event_type) + editions = q.order_by(EventEdition.event_date.desc().nullslast(), EventEdition.id.desc()).all() + return [edition_to_public(e) for e in editions] + + +@router.get("/editions/{slug}", response_model=EventEditionPublic) +def get_edition(slug: str, db: Session = Depends(get_db)): + edition = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.slug == slug, EventEdition.is_published.is_(True)) + .first() + ) + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + return edition_to_public(edition) + + +@router.get("/go") +def outbound_redirect( + request: Request, + url: str = Query(..., min_length=8, max_length=512), + kind: str = Query(default="other", max_length=64), + edition_id: int | None = Query(default=None), + db: Session = Depends(get_db), +): + try: + destination = validate_outbound_url(url) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid destination URL") + + db.add( + OutboundClick( + link_kind=kind[:64], + destination_url=destination, + edition_id=edition_id, + referrer=request.headers.get("referer"), + user_agent=(request.headers.get("user-agent") or "")[:512], + ) + ) + db.commit() + maybe_notify_click_spike(db) + return RedirectResponse(destination, status_code=302) + + +@router.post("/subscribe") +def subscribe(body: SubscribeRequest, db: Session = Depends(get_db)): + existing = db.query(NewsletterSubscriber).filter(NewsletterSubscriber.email == body.email).first() + if existing: + return {"ok": True, "message": "Already on the list — we'll see you in Bengaluru."} + db.add(NewsletterSubscriber(email=body.email, neighborhood=body.neighborhood)) + db.commit() + return {"ok": True, "message": "You're in! We'll email you when the next edition drops."} + + +@router.get("/editions/{slug}/calendar.ics", response_class=PlainTextResponse) +def edition_calendar(slug: str, db: Session = Depends(get_db)): + edition = ( + db.query(EventEdition) + .filter(EventEdition.slug == slug, EventEdition.is_published.is_(True)) + .first() + ) + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}" + return PlainTextResponse( + build_ics(edition, page_url), + media_type="text/calendar", + headers={"Content-Disposition": f'attachment; filename="{slug}.ics"'}, + ) + + +def _share_html_for_slug(slug: str, db: Session) -> str: + edition = ( + db.query(EventEdition) + .options(joinedload(EventEdition.media)) + .filter(EventEdition.slug == slug, EventEdition.is_published.is_(True)) + .first() + ) + if not edition: + raise HTTPException(status_code=404, detail="Edition not found") + return render_share_html(edition_to_public(edition), slug) + + +@router.get("/share/events/{slug}", response_class=HTMLResponse) +def share_event_preview_api(slug: str, db: Session = Depends(get_db)): + return HTMLResponse(_share_html_for_slug(slug, db)) diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py new file mode 100644 index 0000000..caa183d --- /dev/null +++ b/backend/app/routers/share.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.database import get_db +from app.routers.public import _share_html_for_slug + +router = APIRouter(tags=["share"]) + + +@router.get("/share/events/{slug}", response_class=HTMLResponse) +def share_event_short_url(slug: str, db: Session = Depends(get_db)): + return HTMLResponse(_share_html_for_slug(slug, db)) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..c5a3e71 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.auth import LoginRequest, RegisterRequest, Token, UserPublic +from app.schemas.domain import * # noqa: F403 +from app.schemas.health import HealthResponse diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..cde6a59 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,181 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + +from app.models.enums import AdminRole, EventStatus, EventType, OrderStatus, TicketTypeStatus + + +class AdminLoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class AdminToken(BaseModel): + access_token: str + token_type: str = "bearer" + + +class AdminUserPublic(BaseModel): + id: int + email: EmailStr + role: AdminRole + + model_config = {"from_attributes": True} + + +class DashboardMetrics(BaseModel): + total_revenue_paise: int + tickets_sold: int + tickets_remaining: int + orders_total: int + orders_paid: int + orders_pending: int + attendance: int + upcoming_events: int + + +class EventStatsRow(BaseModel): + event_id: int + event_title: str + starts_at: datetime + tickets_sold: int + tickets_remaining: int + revenue_paise: int + attendance: int + + +class VenueAdmin(BaseModel): + id: int + slug: str + name: str + area: str + city: str + address: str + map_url: str | None = None + description: str | None = None + + model_config = {"from_attributes": True} + + +class VenueCreate(BaseModel): + slug: str = Field(min_length=2, max_length=120) + name: str = Field(min_length=2, max_length=200) + area: str = Field(min_length=2, max_length=120) + city: str = Field(default="Bengaluru", max_length=80) + address: str = Field(min_length=5, max_length=512) + map_url: str | None = Field(default=None, max_length=512) + description: str | None = None + + +class VenueUpdate(BaseModel): + slug: str | None = Field(default=None, max_length=120) + name: str | None = Field(default=None, max_length=200) + area: str | None = Field(default=None, max_length=120) + city: str | None = Field(default=None, max_length=80) + address: str | None = Field(default=None, max_length=512) + map_url: str | None = Field(default=None, max_length=512) + description: str | None = None + + +class EventAdmin(BaseModel): + id: int + slug: str + title: str + category: EventType + status: EventStatus + venue_id: int + description: str | None + starts_at: datetime + ends_at: datetime | None + is_featured: bool + cover_image_url: str | None + cover_image_alt: str | None + + model_config = {"from_attributes": True} + + +class EventCreate(BaseModel): + slug: str = Field(min_length=2, max_length=200) + title: str = Field(min_length=2, max_length=240) + category: EventType + status: EventStatus = EventStatus.draft + venue_id: int + description: str | None = None + starts_at: datetime + ends_at: datetime | None = None + is_featured: bool = False + cover_image_url: str | None = None + cover_image_alt: str | None = None + + +class EventUpdate(BaseModel): + slug: str | None = Field(default=None, max_length=200) + title: str | None = Field(default=None, max_length=240) + category: EventType | None = None + status: EventStatus | None = None + venue_id: int | None = None + description: str | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + is_featured: bool | None = None + cover_image_url: str | None = None + cover_image_alt: str | None = None + + +class TicketTypeAdmin(BaseModel): + id: int + event_id: int + name: str + description: str | None + price_paise: int + quantity_total: int + quantity_sold: int + remaining: int + status: TicketTypeStatus + sort_order: int + + model_config = {"from_attributes": True} + + +class TicketTypeCreate(BaseModel): + event_id: int + name: str = Field(min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=512) + price_paise: int = Field(ge=0) + quantity_total: int = Field(ge=0) + status: TicketTypeStatus = TicketTypeStatus.active + sort_order: int = 0 + + +class TicketTypeUpdate(BaseModel): + name: str | None = Field(default=None, max_length=120) + description: str | None = Field(default=None, max_length=512) + price_paise: int | None = Field(default=None, ge=0) + quantity_total: int | None = Field(default=None, ge=0) + status: TicketTypeStatus | None = None + sort_order: int | None = None + + +class OrderAdmin(BaseModel): + id: int + user_id: int + event_id: int + status: OrderStatus + total_paise: int + currency: str + provider_order_id: str | None + provider_payment_id: str | None + paid_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class PaymentAdmin(BaseModel): + order_id: int + event_id: int + status: OrderStatus + total_paise: int + provider_payment_id: str | None + payment_reference: str | None + paid_at: datetime | None diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..a08211f --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str) -> str: + return value.strip().lower() + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=120) + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str) -> str: + return value.strip().lower() + + @field_validator("full_name") + @classmethod + def strip_name(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + return stripped or None + + +class UserPublic(BaseModel): + id: int + email: EmailStr + full_name: str | None + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py new file mode 100644 index 0000000..aeb3e0c --- /dev/null +++ b/backend/app/schemas/catalog.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.models.enums import EventStatus, EventType, TicketTypeStatus + + +class VenuePublic(BaseModel): + id: int + slug: str + name: str + area: str + city: str + address: str + map_url: str | None = None + description: str | None = None + + model_config = {"from_attributes": True} + + +class EventMediaPublic(BaseModel): + id: int + image_url: str + alt_text: str | None + sort_order: int + + model_config = {"from_attributes": True} + + +class TicketTypePublic(BaseModel): + id: int + name: str + description: str | None + price_paise: int = Field(ge=0) + status: TicketTypeStatus + sort_order: int + remaining_quantity: int = Field(ge=0) + + model_config = {"from_attributes": True} + + +class EventSummary(BaseModel): + id: int + slug: str + title: str + category: EventType + status: EventStatus + starts_at: datetime + ends_at: datetime | None + is_featured: bool + cover_image_url: str | None + cover_image_alt: str | None + venue: VenuePublic + + model_config = {"from_attributes": True} + + +class EventDetail(EventSummary): + description: str | None + meta_description: str | None + media: list[EventMediaPublic] + ticket_types: list[TicketTypePublic] diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py new file mode 100644 index 0000000..fd3d811 --- /dev/null +++ b/backend/app/schemas/domain.py @@ -0,0 +1,170 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + +from app.models.enums import AdminRole, EventType, PlatformKind + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class SubscribeRequest(BaseModel): + email: EmailStr + neighborhood: str | None = Field(default=None, max_length=120) + + +class SubscriberPublic(BaseModel): + id: int + email: str + neighborhood: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminUserPublic(BaseModel): + id: int + email: str + role: AdminRole + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminUserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + role: AdminRole = AdminRole.editor + + +class PlatformLinkPublic(BaseModel): + id: int + kind: PlatformKind + label: str + url: str + sort_order: int + + model_config = {"from_attributes": True} + + +class PlatformLinkCreate(BaseModel): + kind: PlatformKind + label: str = Field(max_length=120) + url: str = Field(max_length=512) + sort_order: int = 0 + is_active: bool = True + + +class PlatformLinkUpdate(BaseModel): + kind: PlatformKind | None = None + label: str | None = Field(default=None, max_length=120) + url: str | None = Field(default=None, max_length=512) + sort_order: int | None = None + is_active: bool | None = None + + +class EditionTicketsPublic(BaseModel): + bookmyshow: str | None = None + district: str | None = None + instagram: str | None = None + + +class MediaAssetPublic(BaseModel): + id: int + url: str + thumb_url: str | None = None + caption: str | None + sort_order: int + + model_config = {"from_attributes": True} + + +class ClickSummaryItem(BaseModel): + link_kind: str + count: int + + +class OutboundClickPublic(BaseModel): + id: int + link_kind: str + destination_url: str + edition_id: int | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class EventEditionPublic(BaseModel): + id: int + title: str + slug: str + event_type: EventType + city: str + venue_name: str | None + event_date: datetime | None + description: str | None + is_featured: bool + cover_image_url: str | None + cover_thumb_url: str | None = None + tickets: EditionTicketsPublic + meta_description: str | None + media: list[MediaAssetPublic] + + model_config = {"from_attributes": True} + + +class EventEditionAdmin(EventEditionPublic): + is_published: bool + sort_order: int + + +class EventEditionCreate(BaseModel): + title: str = Field(max_length=200) + slug: str = Field(max_length=200) + event_type: EventType + venue_name: str | None = Field(default=None, max_length=200) + event_date: datetime | None = None + description: str | None = None + is_published: bool = False + is_featured: bool = False + sort_order: int = 0 + bookmyshow_url: str | None = Field(default=None, max_length=512) + district_url: str | None = Field(default=None, max_length=512) + instagram_url: str | None = Field(default=None, max_length=512) + meta_description: str | None = Field(default=None, max_length=320) + + +class EventEditionUpdate(BaseModel): + title: str | None = Field(default=None, max_length=200) + slug: str | None = Field(default=None, max_length=200) + event_type: EventType | None = None + venue_name: str | None = None + event_date: datetime | None = None + description: str | None = None + is_published: bool | None = None + is_featured: bool | None = None + sort_order: int | None = None + bookmyshow_url: str | None = Field(default=None, max_length=512) + district_url: str | None = Field(default=None, max_length=512) + instagram_url: str | None = Field(default=None, max_length=512) + meta_description: str | None = Field(default=None, max_length=320) + + +class SiteConfigPublic(BaseModel): + city: str = "Bengaluru" + tagline: str + platform_links: list[PlatformLinkPublic] + featured_edition: EventEditionPublic | None + recent_editions: list[EventEditionPublic] + + +class MediaReorderItem(BaseModel): + id: int + sort_order: int diff --git a/backend/app/schemas/health.py b/backend/app/schemas/health.py new file mode 100644 index 0000000..ca44fe5 --- /dev/null +++ b/backend/app/schemas/health.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel): + status: str = Field(examples=["ok"]) + service: str = Field(examples=["desi-clans-api"]) + database: str = Field(examples=["connected"]) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py new file mode 100644 index 0000000..cb150cc --- /dev/null +++ b/backend/app/schemas/orders.py @@ -0,0 +1,90 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.models.enums import OrderStatus, TicketStatus, TicketTypeStatus + + +class PaymentCheckoutPublic(BaseModel): + provider: str + key_id: str + provider_order_id: str + amount_paise: int + currency: str + order_id: int + expires_at: datetime | None = None + timeout_minutes: int + + +class EventTicketTypePublic(BaseModel): + id: int + event_id: int + name: str + description: str | None + price_paise: int = Field(ge=0) + status: TicketTypeStatus + sort_order: int + remaining_quantity: int = Field(ge=0) + + model_config = {"from_attributes": True} + + +class OrderLineCreate(BaseModel): + ticket_type_id: int = Field(gt=0) + quantity: int = Field(ge=1, le=20) + + +class OrderCreate(BaseModel): + event_id: int = Field(gt=0) + items: list[OrderLineCreate] = Field(min_length=1, max_length=20) + + +class OrderItemPublic(BaseModel): + id: int + ticket_type_id: int + quantity: int + unit_price_paise: int + line_total_paise: int + + model_config = {"from_attributes": True} + + +class OrderPublic(BaseModel): + id: int + event_id: int + status: OrderStatus + total_paise: int + currency: str + payment_reference: str | None + provider_order_id: str | None = None + provider_payment_id: str | None = None + expires_at: datetime | None = None + paid_at: datetime | None = None + created_at: datetime + items: list[OrderItemPublic] + + model_config = {"from_attributes": True} + + +class OrderCheckoutResponse(OrderPublic): + payment: PaymentCheckoutPublic + + +class PaymentVerifyRequest(BaseModel): + provider_order_id: str = Field(min_length=1, max_length=64) + provider_payment_id: str = Field(min_length=1, max_length=64) + signature: str = Field(min_length=1, max_length=256) + + +class TicketPublic(BaseModel): + id: int + code: str + order_id: int + ticket_type_id: int + event_id: int + status: TicketStatus + created_at: datetime + ticket_type_name: str | None = None + event_title: str | None = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tickets.py b/backend/app/schemas/tickets.py new file mode 100644 index 0000000..bd344da --- /dev/null +++ b/backend/app/schemas/tickets.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.models.enums import TicketStatus + + +class TicketVerifyRequest(BaseModel): + qr_token: str = Field(min_length=16, max_length=128) + + +class TicketVerifyResponse(BaseModel): + valid: bool + status: TicketStatus + ticket_id: int + ticket_code: str + event_title: str + message: str + + +class TicketDetailPublic(BaseModel): + id: int + code: str + status: TicketStatus + order_id: int + event_id: int + event_title: str + event_starts_at: datetime + event_ends_at: datetime | None + venue_name: str + venue_area: str + venue_city: str + attendee_name: str + attendee_email: str + ticket_type_name: str + ticket_type_id: int + qr_token: str + qr_payload: str + qr_image_base64: str + used_at: datetime | None + created_at: datetime diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/admin/__init__.py b/backend/app/services/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/admin/catalog.py b/backend/app/services/admin/catalog.py new file mode 100644 index 0000000..824c658 --- /dev/null +++ b/backend/app/services/admin/catalog.py @@ -0,0 +1,126 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.catalog import Event, TicketType, Venue +from app.models.enums import TicketTypeStatus +from app.schemas.admin import ( + EventCreate, + EventUpdate, + TicketTypeCreate, + TicketTypeUpdate, + VenueCreate, + VenueUpdate, +) + + +def list_venues_admin(db: Session) -> list[Venue]: + return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all()) + + +def create_venue(db: Session, body: VenueCreate) -> Venue: + if db.scalar(select(Venue.id).where(Venue.slug == body.slug)): + raise AppError("Venue slug already exists", status_code=409) + venue = Venue(**body.model_dump()) + db.add(venue) + db.commit() + db.refresh(venue) + return venue + + +def update_venue(db: Session, venue_id: int, body: VenueUpdate) -> Venue: + venue = db.get(Venue, venue_id) + if not venue: + raise AppError("Venue not found", status_code=404) + data = body.model_dump(exclude_unset=True) + if "slug" in data and data["slug"] != venue.slug: + if db.scalar(select(Venue.id).where(Venue.slug == data["slug"])): + raise AppError("Venue slug already exists", status_code=409) + for key, value in data.items(): + setattr(venue, key, value) + db.commit() + db.refresh(venue) + return venue + + +def list_events_admin(db: Session) -> list[Event]: + return list(db.scalars(select(Event).order_by(Event.starts_at.desc())).all()) + + +def create_event(db: Session, body: EventCreate) -> Event: + if not db.get(Venue, body.venue_id): + raise AppError("Venue not found", status_code=400) + if db.scalar(select(Event.id).where(Event.slug == body.slug)): + raise AppError("Event slug already exists", status_code=409) + event = Event(**body.model_dump()) + db.add(event) + db.commit() + db.refresh(event) + return event + + +def update_event(db: Session, event_id: int, body: EventUpdate) -> Event: + event = db.get(Event, event_id) + if not event: + raise AppError("Event not found", status_code=404) + data = body.model_dump(exclude_unset=True) + if "venue_id" in data and not db.get(Venue, data["venue_id"]): + raise AppError("Venue not found", status_code=400) + if "slug" in data and data["slug"] != event.slug: + if db.scalar(select(Event.id).where(Event.slug == data["slug"])): + raise AppError("Event slug already exists", status_code=409) + for key, value in data.items(): + setattr(event, key, value) + db.commit() + db.refresh(event) + return event + + +def ticket_type_to_admin(tier: TicketType) -> dict: + return { + "id": tier.id, + "event_id": tier.event_id, + "name": tier.name, + "description": tier.description, + "price_paise": tier.price_paise, + "quantity_total": tier.quantity_total, + "quantity_sold": tier.quantity_sold, + "remaining": max(tier.quantity_total - tier.quantity_sold, 0), + "status": tier.status, + "sort_order": tier.sort_order, + } + + +def list_ticket_types(db: Session, event_id: int | None = None) -> list[dict]: + query = select(TicketType).order_by(TicketType.event_id.asc(), TicketType.sort_order.asc()) + if event_id is not None: + query = query.where(TicketType.event_id == event_id) + return [ticket_type_to_admin(row) for row in db.scalars(query).all()] + + +def create_ticket_type(db: Session, body: TicketTypeCreate) -> TicketType: + if not db.get(Event, body.event_id): + raise AppError("Event not found", status_code=400) + tier = TicketType(**body.model_dump()) + db.add(tier) + db.commit() + db.refresh(tier) + return tier + + +def update_ticket_type(db: Session, tier_id: int, body: TicketTypeUpdate) -> TicketType: + tier = db.get(TicketType, tier_id) + if not tier: + raise AppError("Ticket type not found", status_code=404) + data = body.model_dump(exclude_unset=True) + if "quantity_total" in data and data["quantity_total"] < tier.quantity_sold: + raise AppError("Total quantity cannot be less than sold count", status_code=400) + for key, value in data.items(): + setattr(tier, key, value) + if tier.quantity_sold >= tier.quantity_total: + tier.status = TicketTypeStatus.sold_out + elif tier.status == TicketTypeStatus.sold_out and tier.quantity_sold < tier.quantity_total: + tier.status = TicketTypeStatus.active + db.commit() + db.refresh(tier) + return tier diff --git a/backend/app/services/admin/dashboard.py b/backend/app/services/admin/dashboard.py new file mode 100644 index 0000000..064bb17 --- /dev/null +++ b/backend/app/services/admin/dashboard.py @@ -0,0 +1,82 @@ +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.catalog import Event, TicketType +from app.models.enums import EventStatus, OrderStatus, TicketStatus +from app.models.orders import Order, Ticket +from app.schemas.admin import DashboardMetrics, EventStatsRow + + +def get_dashboard_metrics(db: Session) -> DashboardMetrics: + now = datetime.now(timezone.utc) + + revenue = db.scalar( + select(func.coalesce(func.sum(Order.total_paise), 0)).where(Order.status == OrderStatus.paid) + ) or 0 + + tickets_sold = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_sold), 0))) or 0 + tickets_total = db.scalar(select(func.coalesce(func.sum(TicketType.quantity_total), 0))) or 0 + tickets_remaining = max(int(tickets_total) - int(tickets_sold), 0) + + orders_total = db.scalar(select(func.count(Order.id))) or 0 + orders_paid = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.paid)) or 0 + orders_pending = db.scalar(select(func.count(Order.id)).where(Order.status == OrderStatus.pending)) or 0 + + attendance = db.scalar(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.used)) or 0 + + upcoming_events = ( + db.scalar( + select(func.count(Event.id)).where( + Event.status == EventStatus.published, + Event.starts_at >= now, + ) + ) + or 0 + ) + + return DashboardMetrics( + total_revenue_paise=int(revenue), + tickets_sold=int(tickets_sold), + tickets_remaining=tickets_remaining, + orders_total=int(orders_total), + orders_paid=int(orders_paid), + orders_pending=int(orders_pending), + attendance=int(attendance), + upcoming_events=int(upcoming_events), + ) + + +def get_event_statistics(db: Session) -> list[EventStatsRow]: + events = db.scalars(select(Event).order_by(Event.starts_at.desc())).all() + rows: list[EventStatsRow] = [] + + for event in events: + tiers = db.scalars(select(TicketType).where(TicketType.event_id == event.id)).all() + sold = sum(t.quantity_sold for t in tiers) + total = sum(t.quantity_total for t in tiers) + revenue = db.scalar( + select(func.coalesce(func.sum(Order.total_paise), 0)).where( + Order.event_id == event.id, + Order.status == OrderStatus.paid, + ) + ) or 0 + attendance = db.scalar( + select(func.count(Ticket.id)).where( + Ticket.event_id == event.id, + Ticket.status == TicketStatus.used, + ) + ) or 0 + rows.append( + EventStatsRow( + event_id=event.id, + event_title=event.title, + starts_at=event.starts_at, + tickets_sold=sold, + tickets_remaining=max(total - sold, 0), + revenue_paise=int(revenue), + attendance=int(attendance), + ) + ) + return rows diff --git a/backend/app/services/admin/orders.py b/backend/app/services/admin/orders.py new file mode 100644 index 0000000..0fd9fbc --- /dev/null +++ b/backend/app/services/admin/orders.py @@ -0,0 +1,40 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.models.enums import OrderStatus +from app.models.orders import Order + + +def list_orders_admin(db: Session, status: OrderStatus | None = None) -> list[Order]: + query = select(Order).order_by(Order.created_at.desc()) + if status is not None: + query = query.where(Order.status == status) + return list(db.scalars(query).all()) + + +def get_order_admin(db: Session, order_id: int) -> Order: + order = db.get(Order, order_id) + if not order: + raise AppError("Order not found", status_code=404) + return order + + +def list_payments_admin(db: Session) -> list[dict]: + orders = db.scalars( + select(Order) + .where(Order.status.in_([OrderStatus.paid, OrderStatus.pending, OrderStatus.failed])) + .order_by(Order.created_at.desc()) + ).all() + return [ + { + "order_id": order.id, + "event_id": order.event_id, + "status": order.status, + "total_paise": order.total_paise, + "provider_payment_id": order.provider_payment_id, + "payment_reference": order.payment_reference, + "paid_at": order.paid_at, + } + for order in orders + ] diff --git a/backend/app/services/alerts.py b/backend/app/services/alerts.py new file mode 100644 index 0000000..5e32920 --- /dev/null +++ b/backend/app/services/alerts.py @@ -0,0 +1,39 @@ +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy.orm import Session + +from app.config import settings +from app.models import AlertDispatch, OutboundClick +from app.services.email import send_email + +TICKET_KINDS = ("bookmyshow", "district") + + +def maybe_notify_click_spike(db: Session) -> None: + if not settings.smtp_configured: + return + + since = datetime.now(timezone.utc) - timedelta(hours=24) + count = ( + db.query(OutboundClick) + .filter(OutboundClick.created_at >= since, OutboundClick.link_kind.in_(TICKET_KINDS)) + .count() + ) + if count < settings.alert_click_threshold: + return + + alert_key = f"click_spike_{date.today().isoformat()}" + if db.query(AlertDispatch).filter(AlertDispatch.alert_key == alert_key).first(): + return + + send_email( + subject=f"Desi Clans — ticket click spike ({count} in 24h)", + body=( + f"Ticket outbound clicks in the last 24 hours: {count}\n" + f"Threshold: {settings.alert_click_threshold}\n" + f"City: Bengaluru\n" + "Check admin analytics for details." + ), + ) + db.add(AlertDispatch(alert_key=alert_key)) + db.commit() diff --git a/backend/app/services/calendar.py b/backend/app/services/calendar.py new file mode 100644 index 0000000..24d822a --- /dev/null +++ b/backend/app/services/calendar.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta, timezone + +from app.models import EventEdition + + +def _fmt_ics(dt: datetime) -> str: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.strftime("%Y%m%dT%H%M%SZ") + + +def build_ics(edition: EventEdition, page_url: str) -> str: + start = edition.event_date or datetime.now(timezone.utc) + timedelta(days=7) + end = start + timedelta(hours=4) + uid = f"desi-clans-{edition.slug}@bengaluru" + location = edition.venue_name or edition.city + summary = edition.title.replace(",", "\\,") + desc = (edition.meta_description or edition.description or edition.title).replace("\n", "\\n") + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Desi Clans//Bengaluru Events//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + f"UID:{uid}", + f"DTSTAMP:{_fmt_ics(datetime.now(timezone.utc))}", + f"DTSTART:{_fmt_ics(start)}", + f"DTEND:{_fmt_ics(end)}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{desc} — {page_url}", + f"LOCATION:{location}", + f"URL:{page_url}", + "END:VEVENT", + "END:VCALENDAR", + ] + return "\r\n".join(lines) + "\r\n" diff --git a/backend/app/services/catalog.py b/backend/app/services/catalog.py new file mode 100644 index 0000000..e923040 --- /dev/null +++ b/backend/app/services/catalog.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.models.catalog import Event, EventMedia, TicketType, Venue +from app.models.enums import EventStatus, EventType +from app.schemas.catalog import EventDetail, EventSummary, TicketTypePublic, VenuePublic + + +def _ticket_type_public(ticket: TicketType) -> TicketTypePublic: + return TicketTypePublic( + id=ticket.id, + name=ticket.name, + description=ticket.description, + price_paise=ticket.price_paise, + status=ticket.status, + sort_order=ticket.sort_order, + remaining_quantity=max(ticket.quantity_total - ticket.quantity_sold, 0), + ) + + +def list_events( + db: Session, + *, + category: EventType | None, + status: EventStatus | None, + upcoming: bool, +) -> list[Event]: + query = ( + select(Event) + .options(joinedload(Event.venue)) + .order_by(Event.starts_at.asc()) + ) + + if status is not None: + query = query.where(Event.status == status) + if category is not None: + query = query.where(Event.category == category) + if upcoming: + now = datetime.now(timezone.utc) + query = query.where(Event.starts_at >= now) + + return list(db.scalars(query).unique().all()) + + +def get_event_by_slug(db: Session, slug: str) -> Event: + event = db.scalar( + select(Event) + .options( + joinedload(Event.venue), + joinedload(Event.media), + joinedload(Event.ticket_types), + ) + .where(Event.slug == slug) + ) + if not event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found") + return event + + +def event_to_summary(event: Event) -> EventSummary: + return EventSummary.model_validate(event) + + +def event_to_detail(event: Event) -> EventDetail: + return EventDetail( + id=event.id, + slug=event.slug, + title=event.title, + category=event.category, + status=event.status, + starts_at=event.starts_at, + ends_at=event.ends_at, + is_featured=event.is_featured, + cover_image_url=event.cover_image_url, + cover_image_alt=event.cover_image_alt, + venue=VenuePublic.model_validate(event.venue), + description=event.description, + meta_description=event.meta_description, + media=event.media, + ticket_types=[_ticket_type_public(ticket) for ticket in event.ticket_types], + ) + + +def list_venues(db: Session) -> list[Venue]: + return list(db.scalars(select(Venue).order_by(Venue.name.asc())).all()) + + +def get_venue_by_id(db: Session, venue_id: int) -> Venue: + venue = db.get(Venue, venue_id) + if not venue: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Venue not found") + return venue diff --git a/backend/app/services/editions.py b/backend/app/services/editions.py new file mode 100644 index 0000000..2f9cbd2 --- /dev/null +++ b/backend/app/services/editions.py @@ -0,0 +1,60 @@ +from app.models import EventEdition, MediaAsset +from app.schemas import EditionTicketsPublic, EventEditionAdmin, EventEditionPublic, MediaAssetPublic + + +def tickets_for_edition(edition: EventEdition) -> EditionTicketsPublic: + return EditionTicketsPublic( + bookmyshow=edition.bookmyshow_url, + district=edition.district_url, + instagram=edition.instagram_url, + ) + + +def edition_to_public(edition: EventEdition, include_unpublished_media: bool = False) -> EventEditionPublic: + cover = None + if edition.cover_image_path: + from app.services.media import public_url_for_path, thumb_url_for_path + + cover = public_url_for_path(edition.cover_image_path) + + media: list[MediaAssetPublic] = [] + for m in edition.media: + if not include_unpublished_media and not m.is_published: + continue + from app.services.media import public_url_for_path, thumb_url_for_path + + media.append( + MediaAssetPublic( + id=m.id, + url=public_url_for_path(m.file_path), + thumb_url=thumb_url_for_path(m.file_path), + caption=m.caption, + sort_order=m.sort_order, + ) + ) + + return EventEditionPublic( + id=edition.id, + title=edition.title, + slug=edition.slug, + event_type=edition.event_type, + city=edition.city, + venue_name=edition.venue_name, + event_date=edition.event_date, + description=edition.description, + is_featured=edition.is_featured, + cover_image_url=cover, + cover_thumb_url=thumb_url_for_path(edition.cover_image_path) if edition.cover_image_path else None, + tickets=tickets_for_edition(edition), + meta_description=edition.meta_description, + media=media, + ) + + +def edition_to_admin(edition: EventEdition) -> EventEditionAdmin: + base = edition_to_public(edition, include_unpublished_media=True) + return EventEditionAdmin( + **base.model_dump(), + is_published=edition.is_published, + sort_order=edition.sort_order, + ) diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..11e609b --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,21 @@ +import smtplib +from email.message import EmailMessage + +from app.config import settings + + +def send_email(subject: str, body: str) -> None: + if not settings.smtp_configured: + return + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = settings.smtp_from or settings.smtp_user or "desi-clans@localhost" + msg["To"] = settings.alert_notify_email + msg.set_content(body) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as server: + if settings.smtp_use_tls: + server.starttls() + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_password) + server.send_message(msg) diff --git a/backend/app/services/health.py b/backend/app/services/health.py new file mode 100644 index 0000000..3a8dbbe --- /dev/null +++ b/backend/app/services/health.py @@ -0,0 +1,11 @@ +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + + +def check_database(db: Session) -> bool: + try: + db.execute(text("SELECT 1")) + return True + except SQLAlchemyError: + return False diff --git a/backend/app/services/media.py b/backend/app/services/media.py new file mode 100644 index 0000000..f46b291 --- /dev/null +++ b/backend/app/services/media.py @@ -0,0 +1,70 @@ +import io +import re +import uuid +from pathlib import Path + +from fastapi import UploadFile +from PIL import Image + +from app.storage import get_storage + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +MAX_BYTES = 8 * 1024 * 1024 + +VARIANTS = { + "lg": 2400, + "md": 1200, + "thumb": 400, +} + + +def public_url_for_path(file_path: str) -> str: + normalized = file_path.replace("\\", "/").lstrip("/") + return get_storage().public_url(normalized) + + +def thumb_path_for(file_path: str) -> str | None: + normalized = file_path.replace("\\", "/") + if "_lg." in normalized: + return re.sub(r"_lg\.(\w+)$", r"_thumb.\1", normalized) + base, ext = normalized.rsplit(".", 1) if "." in normalized else (normalized, "webp") + return f"{base}_thumb.{ext}" + + +def thumb_url_for_path(file_path: str) -> str | None: + thumb = thumb_path_for(file_path) + if not thumb: + return None + return get_storage().public_url(thumb) + + +def _encode_variant(img: Image.Image, max_edge: int) -> bytes: + work = img.convert("RGB") if img.mode in ("RGBA", "P") else img + w, h = work.size + if max(w, h) > max_edge: + ratio = max_edge / max(w, h) + work = work.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS) + buf = io.BytesIO() + work.save(buf, format="WEBP", quality=85 if max_edge >= 1200 else 80) + return buf.getvalue() + + +def save_image(upload: UploadFile) -> str: + suffix = Path(upload.filename or "").suffix.lower() + if suffix not in ALLOWED_EXTENSIONS: + raise ValueError("Only JPG, PNG, and WebP images are allowed") + + raw = upload.file.read() + if len(raw) > MAX_BYTES: + raise ValueError("Image must be under 8 MB") + + storage = get_storage() + base = uuid.uuid4().hex + primary_key = f"{base}_lg.webp" + + with Image.open(io.BytesIO(raw)) as img: + for variant, edge in VARIANTS.items(): + key = f"{base}_{variant}.webp" + storage.save(key, _encode_variant(img, edge), "image/webp") + + return primary_key diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py new file mode 100644 index 0000000..534b084 --- /dev/null +++ b/backend/app/services/orders.py @@ -0,0 +1,335 @@ +import secrets +from collections import defaultdict +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.core.config import get_settings +from app.core.errors import AppError +from app.models.catalog import Event, TicketType +from app.models.enums import EventStatus, OrderStatus, TicketStatus, TicketTypeStatus +from app.models.orders import Order, OrderItem, Ticket +from app.models.user import User +from app.schemas.orders import OrderCreate +from app.services.payments import get_payment_gateway +from app.services.tickets import generate_qr_token + + +class InsufficientInventoryError(AppError): + def __init__(self, message: str = "Not enough tickets available") -> None: + super().__init__(message, status_code=409) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _remaining(ticket_type: TicketType) -> int: + return max(ticket_type.quantity_total - ticket_type.quantity_sold, 0) + + +def _ticket_type_public_row(ticket_type: TicketType) -> dict: + return { + "id": ticket_type.id, + "event_id": ticket_type.event_id, + "name": ticket_type.name, + "description": ticket_type.description, + "price_paise": ticket_type.price_paise, + "status": ticket_type.status, + "sort_order": ticket_type.sort_order, + "remaining_quantity": _remaining(ticket_type), + } + + +def list_event_ticket_types(db: Session, event_id: int) -> list[dict]: + event = db.get(Event, event_id) + if not event or event.status != EventStatus.published: + raise AppError("Event not found", status_code=404) + + rows = db.scalars( + select(TicketType) + .where(TicketType.event_id == event_id) + .order_by(TicketType.sort_order.asc(), TicketType.id.asc()) + ).all() + return [_ticket_type_public_row(row) for row in rows] + + +def _generate_ticket_code() -> str: + return f"DC-{secrets.token_hex(8).upper()}" + + +def expire_stale_pending_orders(db: Session) -> None: + settings = get_settings() + cutoff = _utcnow() + stale_orders = list( + db.scalars( + select(Order) + .where( + Order.status == OrderStatus.pending, + Order.expires_at.is_not(None), + Order.expires_at < cutoff, + ) + .with_for_update() + ).all() + ) + for order in stale_orders: + cancel_pending_order(db, order, status=OrderStatus.failed) + + +def get_order_by_provider_order_id(db: Session, provider_order_id: str) -> Order | None: + return db.scalar( + select(Order) + .options(joinedload(Order.items)) + .where(Order.provider_order_id == provider_order_id) + ) + + +def get_order_by_idempotency_key(db: Session, key: str) -> Order | None: + return db.scalar( + select(Order) + .options(joinedload(Order.items)) + .where(Order.idempotency_key == key) + ) + + +def _reserve_inventory( + db: Session, + body: OrderCreate, +) -> tuple[int, list[tuple[TicketType, int, int]]]: + line_map: dict[int, int] = defaultdict(int) + for line in body.items: + line_map[line.ticket_type_id] += line.quantity + + if not line_map: + raise AppError("Order must include at least one ticket", status_code=400) + + ticket_type_ids = sorted(line_map.keys()) + locked_types = list( + db.scalars( + select(TicketType) + .where(TicketType.id.in_(ticket_type_ids)) + .order_by(TicketType.id.asc()) + .with_for_update() + ).all() + ) + + if len(locked_types) != len(ticket_type_ids): + raise AppError("One or more ticket types are invalid", status_code=400) + + total_paise = 0 + priced_lines: list[tuple[TicketType, int, int]] = [] + + for ticket_type in locked_types: + qty = line_map[ticket_type.id] + if ticket_type.event_id != body.event_id: + raise AppError("Ticket type does not belong to this event", status_code=400) + if ticket_type.status != TicketTypeStatus.active: + raise InsufficientInventoryError(f"{ticket_type.name} is not available") + remaining = _remaining(ticket_type) + if qty > remaining: + raise InsufficientInventoryError( + f"Only {remaining} tickets left for {ticket_type.name}", + ) + line_total = ticket_type.price_paise * qty + total_paise += line_total + priced_lines.append((ticket_type, qty, line_total)) + + return total_paise, priced_lines + + +def _apply_inventory_reservation(priced_lines: list[tuple[TicketType, int, int]]) -> None: + for ticket_type, qty, _line_total in priced_lines: + ticket_type.quantity_sold += qty + if ticket_type.quantity_sold >= ticket_type.quantity_total: + ticket_type.quantity_sold = min(ticket_type.quantity_sold, ticket_type.quantity_total) + ticket_type.status = TicketTypeStatus.sold_out + + +def _release_inventory_for_order(db: Session, order: Order) -> None: + for item in order.items: + ticket_type = db.get(TicketType, item.ticket_type_id) + if not ticket_type: + continue + ticket_type.quantity_sold = max(ticket_type.quantity_sold - item.quantity, 0) + if ticket_type.status == TicketTypeStatus.sold_out and _remaining(ticket_type) > 0: + ticket_type.status = TicketTypeStatus.active + + +def create_order( + db: Session, + user: User, + body: OrderCreate, + *, + idempotency_key: str | None = None, +) -> Order: + expire_stale_pending_orders(db) + + if idempotency_key: + existing = get_order_by_idempotency_key(db, idempotency_key) + if existing: + return existing + + event = db.get(Event, body.event_id) + if not event or event.status != EventStatus.published: + raise AppError("Event not found or not available for purchase", status_code=404) + + total_paise, priced_lines = _reserve_inventory(db, body) + + settings = get_settings() + gateway = get_payment_gateway(settings) + + order = Order( + user_id=user.id, + event_id=body.event_id, + status=OrderStatus.pending, + total_paise=total_paise, + currency="INR", + idempotency_key=idempotency_key, + expires_at=_utcnow() + timedelta(minutes=settings.order_payment_timeout_minutes), + ) + db.add(order) + db.flush() + + for ticket_type, qty, line_total in priced_lines: + db.add( + OrderItem( + order_id=order.id, + ticket_type_id=ticket_type.id, + quantity=qty, + unit_price_paise=ticket_type.price_paise, + line_total_paise=line_total, + ) + ) + + _apply_inventory_reservation(priced_lines) + + provider_order = gateway.create_provider_order( + amount_paise=total_paise, + currency="INR", + receipt=str(order.id), + notes={"desi_clans_order_id": str(order.id), "user_id": str(user.id)}, + ) + order.provider_order_id = provider_order.provider_order_id + order.payment_reference = provider_order.provider_order_id + + try: + db.commit() + except Exception: + db.rollback() + raise + db.refresh(order) + _ = order.items + return order + + +def fulfill_order_payment(db: Session, order: Order, *, provider_payment_id: str) -> Order: + if order.status == OrderStatus.paid: + return order + + existing_ticket = db.scalar(select(Ticket).where(Ticket.order_id == order.id).limit(1)) + if not existing_ticket: + items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all() + issued: list[Ticket] = [] + for item in items: + for _ in range(item.quantity): + issued.append( + Ticket( + code=_generate_ticket_code(), + qr_token=generate_qr_token(), + order_id=order.id, + order_item_id=item.id, + ticket_type_id=item.ticket_type_id, + event_id=order.event_id, + user_id=order.user_id, + status=TicketStatus.valid, + ) + ) + db.add_all(issued) + + order.status = OrderStatus.paid + order.provider_payment_id = provider_payment_id + order.paid_at = _utcnow() + order.expires_at = None + db.commit() + db.refresh(order) + _ = order.items + return order + + +def cancel_pending_order( + db: Session, + order: Order, + *, + status: OrderStatus = OrderStatus.cancelled, +) -> Order: + if order.status != OrderStatus.pending: + raise AppError("Only pending orders can be cancelled", status_code=409) + _release_inventory_for_order(db, order) + order.status = status + order.expires_at = None + db.commit() + db.refresh(order) + return order + + +def get_order_for_user(db: Session, order_id: int, user_id: int) -> Order: + expire_stale_pending_orders(db) + order = db.scalar( + select(Order) + .options(joinedload(Order.items)) + .where(Order.id == order_id, Order.user_id == user_id) + ) + if not order: + raise AppError("Order not found", status_code=404) + return order + + +def list_user_tickets(db: Session, user_id: int) -> list[Ticket]: + return list( + db.scalars( + select(Ticket) + .options( + joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type), + joinedload(Ticket.order).joinedload(Order.event), + ) + .where(Ticket.user_id == user_id) + .order_by(Ticket.created_at.desc()) + ) + .unique() + .all() + ) + + +def get_ticket_for_user(db: Session, ticket_id: int, user_id: int) -> Ticket: + ticket = db.scalar( + select(Ticket) + .options( + joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type), + joinedload(Ticket.order).joinedload(Order.event), + ) + .where(Ticket.id == ticket_id, Ticket.user_id == user_id) + ) + if not ticket: + raise AppError("Ticket not found", status_code=404) + return ticket + + +def ticket_to_public(ticket: Ticket) -> dict: + ticket_type_name = None + event_title = None + if ticket.order_item and ticket.order_item.ticket_type: + ticket_type_name = ticket.order_item.ticket_type.name + if ticket.order and ticket.order.event: + event_title = ticket.order.event.title + return { + "id": ticket.id, + "code": ticket.code, + "order_id": ticket.order_id, + "ticket_type_id": ticket.ticket_type_id, + "event_id": ticket.event_id, + "status": ticket.status, + "created_at": ticket.created_at, + "ticket_type_name": ticket_type_name, + "event_title": event_title, + } diff --git a/backend/app/services/outbound.py b/backend/app/services/outbound.py new file mode 100644 index 0000000..7342063 --- /dev/null +++ b/backend/app/services/outbound.py @@ -0,0 +1,10 @@ +from urllib.parse import urlparse + +ALLOWED_SCHEMES = {"http", "https"} + + +def validate_outbound_url(url: str) -> str: + parsed = urlparse(url.strip()) + if parsed.scheme not in ALLOWED_SCHEMES or not parsed.netloc: + raise ValueError("Only http and https URLs are allowed") + return url.strip() diff --git a/backend/app/services/payments/__init__.py b/backend/app/services/payments/__init__.py new file mode 100644 index 0000000..f98d07d --- /dev/null +++ b/backend/app/services/payments/__init__.py @@ -0,0 +1,3 @@ +from app.services.payments.gateway import get_payment_gateway + +__all__ = ["get_payment_gateway"] diff --git a/backend/app/services/payments/gateway.py b/backend/app/services/payments/gateway.py new file mode 100644 index 0000000..d906f5a --- /dev/null +++ b/backend/app/services/payments/gateway.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from app.core.config import Settings, get_settings + + +@dataclass(frozen=True) +class ProviderOrder: + provider_order_id: str + amount_paise: int + currency: str + + +@dataclass(frozen=True) +class PaymentVerification: + provider_order_id: str + provider_payment_id: str + amount_paise: int + currency: str + + +class PaymentGateway(ABC): + @property + @abstractmethod + def provider_name(self) -> str: ... + + @property + @abstractmethod + def public_key_id(self) -> str: ... + + @abstractmethod + def create_provider_order( + self, + *, + amount_paise: int, + currency: str, + receipt: str, + notes: dict[str, str], + ) -> ProviderOrder: ... + + @abstractmethod + def verify_checkout_signature( + self, + *, + provider_order_id: str, + provider_payment_id: str, + signature: str, + ) -> None: ... + + @abstractmethod + def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: ... + + @abstractmethod + def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: ... + + +class MockPaymentGateway(PaymentGateway): + """Local/test gateway — signatures derived server-side only (never exposed to clients).""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._secret = settings.payment_mock_secret + + @property + def provider_name(self) -> str: + return "mock" + + @property + def public_key_id(self) -> str: + return "mock_public_key" + + def create_provider_order( + self, + *, + amount_paise: int, + currency: str, + receipt: str, + notes: dict[str, str], + ) -> ProviderOrder: + token = secrets.token_hex(6) + return ProviderOrder( + provider_order_id=f"mock_order_{receipt}_{token}", + amount_paise=amount_paise, + currency=currency, + ) + + def _expected_signature(self, provider_order_id: str, provider_payment_id: str) -> str: + message = f"{provider_order_id}|{provider_payment_id}".encode() + return hmac.new(self._secret.encode(), message, hashlib.sha256).hexdigest() + + def verify_checkout_signature( + self, + *, + provider_order_id: str, + provider_payment_id: str, + signature: str, + ) -> None: + expected = self._expected_signature(provider_order_id, provider_payment_id) + if not hmac.compare_digest(expected, signature): + raise ValueError("Invalid payment signature") + + def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: + if not signature: + raise ValueError("Missing webhook signature") + expected = hmac.new(self._secret.encode(), body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, signature): + raise ValueError("Invalid webhook signature") + + def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: + event = payload.get("event") + if event != "payment.captured": + return None + entity = payload.get("payload", {}).get("payment", {}).get("entity", {}) + if not entity: + return None + return PaymentVerification( + provider_order_id=entity.get("order_id", ""), + provider_payment_id=entity.get("id", ""), + amount_paise=int(entity.get("amount", 0)), + currency=entity.get("currency", "INR"), + ) + + def build_mock_checkout_signature(self, provider_order_id: str, provider_payment_id: str) -> str: + return self._expected_signature(provider_order_id, provider_payment_id) + + +class RazorpayPaymentGateway(PaymentGateway): + def __init__(self, settings: Settings) -> None: + if not settings.razorpay_key_id or not settings.razorpay_key_secret: + raise RuntimeError("Razorpay credentials are not configured") + import razorpay + + self._client = razorpay.Client(auth=(settings.razorpay_key_id, settings.razorpay_key_secret)) + self._key_id = settings.razorpay_key_id + self._webhook_secret = settings.razorpay_webhook_secret or "" + + @property + def provider_name(self) -> str: + return "razorpay" + + @property + def public_key_id(self) -> str: + return self._key_id + + def create_provider_order( + self, + *, + amount_paise: int, + currency: str, + receipt: str, + notes: dict[str, str], + ) -> ProviderOrder: + data = self._client.order.create( + { + "amount": amount_paise, + "currency": currency, + "receipt": receipt, + "notes": notes, + } + ) + return ProviderOrder( + provider_order_id=data["id"], + amount_paise=int(data["amount"]), + currency=data["currency"], + ) + + def verify_checkout_signature( + self, + *, + provider_order_id: str, + provider_payment_id: str, + signature: str, + ) -> None: + self._client.utility.verify_payment_signature( + { + "razorpay_order_id": provider_order_id, + "razorpay_payment_id": provider_payment_id, + "razorpay_signature": signature, + } + ) + + def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: + if not self._webhook_secret: + raise RuntimeError("RAZORPAY_WEBHOOK_SECRET is not configured") + if not signature: + raise ValueError("Missing webhook signature") + self._client.utility.verify_webhook_signature(body.decode(), signature, self._webhook_secret) + + def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: + event = payload.get("event") + if event not in ("payment.captured", "order.paid"): + return None + payment_entity = payload.get("payload", {}).get("payment", {}).get("entity") + if not payment_entity and event == "order.paid": + payment_entity = payload.get("payload", {}).get("order", {}).get("entity") + if not payment_entity: + return None + order_id = payment_entity.get("order_id") or payment_entity.get("id") + payment_id = payment_entity.get("id") if event == "payment.captured" else payment_entity.get("payment_id") + if not order_id or not payment_id: + return None + return PaymentVerification( + provider_order_id=order_id, + provider_payment_id=payment_id, + amount_paise=int(payment_entity.get("amount", 0)), + currency=payment_entity.get("currency", "INR"), + ) + + +def get_payment_gateway(settings: Settings | None = None) -> PaymentGateway: + settings = settings or get_settings() + mode = settings.payment_provider.lower() + if mode == "razorpay": + return RazorpayPaymentGateway(settings) + return MockPaymentGateway(settings) diff --git a/backend/app/services/payments/service.py b/backend/app/services/payments/service.py new file mode 100644 index 0000000..c95686c --- /dev/null +++ b/backend/app/services/payments/service.py @@ -0,0 +1,203 @@ +import json +import secrets +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.core.config import Settings, get_settings +from app.core.errors import AppError +from app.models.enums import OrderStatus +from app.models.orders import Order +from app.models.payments import PaymentWebhookEvent +from app.services.orders import fulfill_order_payment, get_order_by_provider_order_id +from app.schemas.orders import PaymentCheckoutPublic +from app.services.payments.gateway import PaymentGateway, PaymentVerification, get_payment_gateway + + +class PaymentError(AppError): + pass + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _record_webhook_event( + db: Session, + *, + provider: str, + provider_event_id: str, + event_type: str, + payload: dict, +) -> bool: + """Return False if event was already processed (idempotent).""" + existing = db.scalar( + select(PaymentWebhookEvent).where(PaymentWebhookEvent.provider_event_id == provider_event_id) + ) + if existing: + return False + db.add( + PaymentWebhookEvent( + provider=provider, + provider_event_id=provider_event_id, + event_type=event_type, + payload=json.dumps(payload), + ) + ) + db.flush() + return True + + +def confirm_payment_for_order( + db: Session, + order: Order, + verification: PaymentVerification, + *, + provider_payment_id: str, +) -> Order: + if order.status == OrderStatus.paid: + return order + if order.status not in (OrderStatus.pending,): + raise PaymentError("Order is not awaiting payment", status_code=409) + + if order.provider_order_id and verification.provider_order_id != order.provider_order_id: + raise PaymentError("Payment order mismatch", status_code=400) + if verification.amount_paise != order.total_paise: + raise PaymentError("Payment amount mismatch", status_code=400) + if verification.currency.upper() != order.currency.upper(): + raise PaymentError("Payment currency mismatch", status_code=400) + + return fulfill_order_payment(db, order, provider_payment_id=provider_payment_id) + + +def verify_checkout_and_confirm( + db: Session, + *, + order: Order, + provider_order_id: str, + provider_payment_id: str, + signature: str, + gateway: PaymentGateway | None = None, +) -> Order: + gateway = gateway or get_payment_gateway() + if order.provider_order_id != provider_order_id: + raise PaymentError("Invalid payment order", status_code=400) + try: + gateway.verify_checkout_signature( + provider_order_id=provider_order_id, + provider_payment_id=provider_payment_id, + signature=signature, + ) + except Exception as exc: + raise PaymentError("Payment verification failed", status_code=400) from exc + + verification = PaymentVerification( + provider_order_id=provider_order_id, + provider_payment_id=provider_payment_id, + amount_paise=order.total_paise, + currency=order.currency, + ) + return confirm_payment_for_order(db, order, verification, provider_payment_id=provider_payment_id) + + +def process_webhook( + db: Session, + *, + body: bytes, + signature: str | None, + gateway: PaymentGateway | None = None, +) -> dict: + gateway = gateway or get_payment_gateway() + try: + gateway.verify_webhook_signature(body, signature) + except Exception as exc: + raise PaymentError("Invalid webhook signature", status_code=400) from exc + + payload = json.loads(body.decode()) + event_type = payload.get("event", "unknown") + provider_event_id = payload.get("id") or payload.get("event_id") or secrets.token_hex(8) + + if not _record_webhook_event( + db, + provider=gateway.provider_name, + provider_event_id=str(provider_event_id), + event_type=event_type, + payload=payload, + ): + db.commit() + return {"status": "duplicate_ignored"} + + if event_type == "payment.failed": + order = _order_from_webhook_payload(db, payload) + if order and order.status == OrderStatus.pending: + from app.services.orders import cancel_pending_order + + cancel_pending_order(db, order, status=OrderStatus.failed) + db.commit() + return {"status": "payment_failed_handled"} + + verification = gateway.parse_webhook_payment(payload) + if verification is None: + db.commit() + return {"status": "ignored"} + + order = get_order_by_provider_order_id(db, verification.provider_order_id) + if not order: + db.commit() + return {"status": "order_not_found"} + + confirm_payment_for_order( + db, + order, + verification, + provider_payment_id=verification.provider_payment_id, + ) + db.commit() + return {"status": "paid"} + + +def _order_from_webhook_payload(db: Session, payload: dict) -> Order | None: + entity = payload.get("payload", {}).get("payment", {}).get("entity", {}) + provider_order_id = entity.get("order_id") + if not provider_order_id: + return None + return get_order_by_provider_order_id(db, provider_order_id) + + +def build_checkout_payload(order: Order, gateway: PaymentGateway | None = None) -> PaymentCheckoutPublic: + gateway = gateway or get_payment_gateway() + settings = get_settings() + return PaymentCheckoutPublic( + provider=gateway.provider_name, + key_id=gateway.public_key_id, + provider_order_id=order.provider_order_id or "", + amount_paise=order.total_paise, + currency=order.currency, + order_id=order.id, + expires_at=order.expires_at, + timeout_minutes=settings.order_payment_timeout_minutes, + ) + + +def mock_complete_pending_order(db: Session, order: Order) -> Order: + settings = get_settings() + if settings.payment_provider.lower() != "mock": + raise PaymentError("Mock completion is not enabled", status_code=404) + gateway = get_payment_gateway(settings) + from app.services.payments.gateway import MockPaymentGateway + + if not isinstance(gateway, MockPaymentGateway): + raise PaymentError("Mock gateway required", status_code=404) + + provider_order_id = order.provider_order_id or "" + provider_payment_id = f"mock_pay_{order.id}" + signature = gateway.build_mock_checkout_signature(provider_order_id, provider_payment_id) + return verify_checkout_and_confirm( + db, + order=order, + provider_order_id=provider_order_id, + provider_payment_id=provider_payment_id, + signature=signature, + gateway=gateway, + ) diff --git a/backend/app/services/qr.py b/backend/app/services/qr.py new file mode 100644 index 0000000..8aa0569 --- /dev/null +++ b/backend/app/services/qr.py @@ -0,0 +1,16 @@ +import base64 +import io +import json + +import qrcode + + +def build_qr_payload(qr_token: str) -> str: + return json.dumps({"v": 1, "token": qr_token}, separators=(",", ":")) + + +def qr_png_base64(data: str) -> str: + image = qrcode.make(data, box_size=8, border=2) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return base64.b64encode(buffer.getvalue()).decode("ascii") diff --git a/backend/app/services/share_preview.py b/backend/app/services/share_preview.py new file mode 100644 index 0000000..ee947f2 --- /dev/null +++ b/backend/app/services/share_preview.py @@ -0,0 +1,37 @@ +from html import escape + +from app.config import settings +from app.schemas import EventEditionPublic + + +def render_share_html(public: EventEditionPublic, slug: str) -> str: + title = escape(public.title) + desc = escape( + public.meta_description + or public.description + or f"{public.title} — Desi Clans, Bengaluru" + ) + page_url = f"{settings.public_site_url.rstrip('/')}/events/{slug}" + image = public.cover_image_url or (public.media[0].url if public.media else "") + if image and image.startswith("/"): + image = f"{settings.public_api_url.rstrip('/')}{image}" + image_tag = f'' if image else "" + + return f""" + + + + {title} | Desi Clans + + + + + + {image_tag} + + + + +

{title} — Bengaluru

+ +""" diff --git a/backend/app/services/tickets.py b/backend/app/services/tickets.py new file mode 100644 index 0000000..658a6e6 --- /dev/null +++ b/backend/app/services/tickets.py @@ -0,0 +1,142 @@ +import secrets + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.core.config import Settings, get_settings +from app.core.errors import AppError +from app.models.catalog import Event +from app.models.orders import Order, OrderItem, Ticket +from app.models.enums import TicketStatus +from app.models.user import User +from app.services.qr import build_qr_payload, qr_png_base64 + + +def generate_qr_token() -> str: + return secrets.token_urlsafe(32) + + +def _load_ticket_detail(db: Session, ticket_id: int, user_id: int | None = None) -> Ticket: + query = ( + select(Ticket) + .options( + joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type), + joinedload(Ticket.order).joinedload(Order.event).joinedload(Event.venue), + ) + .where(Ticket.id == ticket_id) + ) + if user_id is not None: + query = query.where(Ticket.user_id == user_id) + ticket = db.scalar(query) + if not ticket: + raise AppError("Ticket not found", status_code=404) + if ticket.order and ticket.order.status.value != "paid": + raise AppError("Ticket not available", status_code=404) + return ticket + + +def ticket_to_detail(db: Session, ticket: Ticket) -> dict: + if not ticket.qr_token: + ticket.qr_token = generate_qr_token() + db.commit() + db.refresh(ticket) + + event = ticket.order.event if ticket.order else None + venue = event.venue if event else None + user = db.get(User, ticket.user_id) + ticket_type = ticket.order_item.ticket_type if ticket.order_item else None + + payload = build_qr_payload(ticket.qr_token) + + return { + "id": ticket.id, + "code": ticket.code, + "status": ticket.status, + "order_id": ticket.order_id, + "event_id": ticket.event_id, + "event_title": event.title if event else "Event", + "event_starts_at": event.starts_at if event else ticket.created_at, + "event_ends_at": event.ends_at if event else None, + "venue_name": venue.name if venue else "Bengaluru", + "venue_area": venue.area if venue else "", + "venue_city": venue.city if venue else "Bengaluru", + "attendee_name": (user.full_name if user and user.full_name else user.email if user else "Guest"), + "attendee_email": user.email if user else "", + "ticket_type_name": ticket_type.name if ticket_type else "General", + "ticket_type_id": ticket.ticket_type_id, + "qr_token": ticket.qr_token, + "qr_payload": payload, + "qr_image_base64": qr_png_base64(payload), + "used_at": ticket.used_at, + "created_at": ticket.created_at, + } + + +def get_ticket_detail_for_user(db: Session, ticket_id: int, user_id: int) -> dict: + ticket = _load_ticket_detail(db, ticket_id, user_id=user_id) + return ticket_to_detail(db, ticket) + + +def verify_ticket_at_gate(db: Session, qr_token: str) -> dict: + ticket = db.scalar(select(Ticket).where(Ticket.qr_token == qr_token).with_for_update()) + if not ticket: + raise AppError("Invalid ticket", status_code=404) + + order = db.get(Order, ticket.order_id) + event = db.get(Event, ticket.event_id) if ticket.event_id else None + event_title = event.title if event else "Event" + + if ticket.status == TicketStatus.used: + return { + "valid": False, + "status": ticket.status, + "ticket_id": ticket.id, + "ticket_code": ticket.code, + "event_title": event_title, + "message": "Ticket already used — QR cannot be reused", + } + + if ticket.status == TicketStatus.cancelled: + return { + "valid": False, + "status": ticket.status, + "ticket_id": ticket.id, + "ticket_code": ticket.code, + "event_title": event_title, + "message": "Ticket cancelled", + } + + if order is None or order.status.value != "paid": + return { + "valid": False, + "status": ticket.status, + "ticket_id": ticket.id, + "ticket_code": ticket.code, + "event_title": event_title, + "message": "Order not paid", + } + + from datetime import datetime, timezone + + ticket.status = TicketStatus.used + ticket.used_at = datetime.now(timezone.utc) + db.commit() + + return { + "valid": True, + "status": TicketStatus.used, + "ticket_id": ticket.id, + "ticket_code": ticket.code, + "event_title": event_title, + "message": "Entry verified", + } + + +def assert_checkin_authorized(settings: Settings, api_key: str | None) -> None: + expected = settings.ticket_verify_api_key + if settings.environment.lower() == "development" and not expected: + return + if not expected or not api_key: + raise AppError("Unauthorized", status_code=401) + if not secrets.compare_digest(api_key, expected): + raise AppError("Unauthorized", status_code=401) diff --git a/backend/app/services/users.py b/backend/app/services/users.py new file mode 100644 index 0000000..80bd4ac --- /dev/null +++ b/backend/app/services/users.py @@ -0,0 +1,41 @@ +from sqlalchemy.orm import Session + +from app.core.security import hash_password, verify_password +from app.models.user import User +from app.schemas.auth import RegisterRequest + + +class EmailAlreadyRegisteredError(Exception): + pass + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + +def get_user_by_id(db: Session, user_id: int) -> User | None: + return db.query(User).filter(User.id == user_id).first() + + +def register_user(db: Session, body: RegisterRequest) -> User: + if get_user_by_email(db, body.email): + raise EmailAlreadyRegisteredError() + user = User( + email=body.email, + hashed_password=hash_password(body.password), + full_name=body.full_name, + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def authenticate_user(db: Session, email: str, password: str) -> User | None: + user = get_user_by_email(db, email) + if not user or not user.is_active: + return None + if not verify_password(password, user.hashed_password): + return None + return user diff --git a/backend/app/storage.py b/backend/app/storage.py new file mode 100644 index 0000000..e0d4f96 --- /dev/null +++ b/backend/app/storage.py @@ -0,0 +1,5 @@ +"""Backward-compatible storage import. Prefer `app.utils.storage`.""" + +from app.utils.storage import StorageBackend, get_storage + +__all__ = ["StorageBackend", "get_storage"] diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/storage.py b/backend/app/utils/storage.py new file mode 100644 index 0000000..fdb30f9 --- /dev/null +++ b/backend/app/utils/storage.py @@ -0,0 +1,74 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from app.core.config import get_settings + +settings = get_settings() + + +class StorageBackend(ABC): + @abstractmethod + def save(self, key: str, data: bytes) -> str: + raise NotImplementedError + + @abstractmethod + def public_url(self, key: str) -> str: + raise NotImplementedError + + +class LocalStorage(StorageBackend): + def __init__(self, root: str) -> None: + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + + def save(self, key: str, data: bytes) -> str: + path = self.root / key.replace("\\", "/").lstrip("/") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return key.replace("\\", "/").lstrip("/") + + def public_url(self, key: str) -> str: + normalized = key.replace("\\", "/").lstrip("/") + return f"/media/{normalized}" + + +class S3Storage(StorageBackend): + def __init__(self) -> None: + import boto3 + + if not settings.s3_bucket: + raise RuntimeError("S3_BUCKET is required when STORAGE_BACKEND=s3") + self.bucket = settings.s3_bucket + self.client = boto3.client( + "s3", + region_name=settings.aws_region, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) + base = settings.s3_public_base_url + self.public_base = ( + base.rstrip("/") if base else f"https://{self.bucket}.s3.{settings.aws_region}.amazonaws.com" + ) + + def save(self, key: str, data: bytes) -> str: + normalized = key.replace("\\", "/").lstrip("/") + self.client.put_object(Bucket=self.bucket, Key=normalized, Body=data) + return normalized + + def public_url(self, key: str) -> str: + normalized = key.replace("\\", "/").lstrip("/") + return f"{self.public_base}/{normalized}" + + +_storage: StorageBackend | None = None + + +def get_storage() -> StorageBackend: + global _storage + if _storage is None: + backend = settings.storage_backend.lower() + if backend == "s3": + _storage = S3Storage() + else: + _storage = LocalStorage(settings.upload_dir) + return _storage diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..998006b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +alembic>=1.14,<2 +bcrypt>=4.0,<5 +boto3>=1.35,<2 +email-validator>=2.2,<3 +fastapi>=0.115,<1 +passlib[bcrypt]>=1.7,<2 +pillow>=11,<12 +psycopg2-binary>=2.9,<3 +pydantic>=2.10,<3 +pydantic-settings>=2.6,<3 +python-jose[cryptography]>=3.3,<4 +python-multipart>=0.0.12,<1 +sqlalchemy>=2.0,<3 +uvicorn[standard]>=0.32,<1 +httpx>=0.28,<1 +pytest>=8,<9 +razorpay>=1.4,<2 +qrcode[pil]>=7.4,<9 + diff --git a/backend/scripts/migrate.py b/backend/scripts/migrate.py new file mode 100644 index 0000000..a46cba5 --- /dev/null +++ b/backend/scripts/migrate.py @@ -0,0 +1,20 @@ +"""Run Alembic migrations (upgrade head).""" + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=ROOT, + check=False, + ) + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py new file mode 100644 index 0000000..5ecf107 --- /dev/null +++ b/backend/scripts/seed.py @@ -0,0 +1,55 @@ +"""Initialize admin user and default Bengaluru platform links.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.auth import hash_password +from app.config import settings +from app.database import SessionLocal, engine +from app.models import AdminUser, Base, PlatformKind, PlatformLink + +DEFAULT_LINKS = [ + (PlatformKind.bookmyshow, "BookMyShow", "https://in.bookmyshow.com/explore/home/bengaluru", 0), + (PlatformKind.district, "District", "https://www.district.in/events/bengaluru", 1), + (PlatformKind.instagram, "Instagram", "https://www.instagram.com/", 2), + (PlatformKind.highape, "HighApe", "https://highape.com/bangalore", 3), +] + +REMOVED_KINDS = (PlatformKind.youtube, PlatformKind.whatsapp) + + +def main(): + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + if not db.query(AdminUser).filter(AdminUser.email == settings.admin_email).first(): + db.add( + AdminUser( + email=settings.admin_email, + hashed_password=hash_password(settings.admin_password), + ) + ) + print(f"Created admin: {settings.admin_email}") + + if db.query(PlatformLink).count() == 0: + for kind, label, url, order in DEFAULT_LINKS: + db.add(PlatformLink(kind=kind, label=label, url=url, sort_order=order, is_active=True)) + print("Seeded default platform links (update URLs in admin)") + else: + for kind in REMOVED_KINDS: + db.query(PlatformLink).filter(PlatformLink.kind == kind).delete() + for kind, label, url, order in DEFAULT_LINKS: + row = db.query(PlatformLink).filter(PlatformLink.kind == kind).first() + if not row: + db.add(PlatformLink(kind=kind, label=label, url=url, sort_order=order, is_active=True)) + print("Synced platform links (removed YouTube/WhatsApp, ensured BMS/District/Insta/HighApe)") + + db.commit() + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/seed_admin.py b/backend/scripts/seed_admin.py new file mode 100644 index 0000000..d7d8fa4 --- /dev/null +++ b/backend/scripts/seed_admin.py @@ -0,0 +1,40 @@ +"""Ensure default admin user exists (catalog admin dashboard).""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select + +from app.core.config import get_settings +from app.core.security import hash_password +from app.db.session import SessionLocal +from app.models.entities import AdminUser +from app.models.enums import AdminRole + + +def main() -> None: + settings = get_settings() + db = SessionLocal() + try: + email = settings.admin_email.strip().lower() + existing = db.scalar(select(AdminUser).where(AdminUser.email == email)) + if not existing: + db.add( + AdminUser( + email=email, + hashed_password=hash_password(settings.admin_password), + role=AdminRole.super_admin, + ) + ) + db.commit() + print(f"Created admin user: {email}") + else: + print(f"Admin user already exists: {email}") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/seed_catalog.py b/backend/scripts/seed_catalog.py new file mode 100644 index 0000000..878d135 --- /dev/null +++ b/backend/scripts/seed_catalog.py @@ -0,0 +1,187 @@ +"""Seed venues, events, media, and ticket types for Holi, Garba, Dandiya.""" + +import sys +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.catalog import Event, EventMedia, TicketType, Venue +from app.models.enums import EventStatus, EventType, TicketTypeStatus + +IMG = lambda photo_id, w=1600: f"https://images.unsplash.com/{photo_id}?auto=format&fit=crop&w={w}&q=80" + +GARBA_IMAGE = IMG("photo-1516450360457-62c5678aefa8") +GARBA_IMAGE_ALT = IMG("photo-1578662996442-48f60103fc96") +HOLI_IMAGE = IMG("photo-1524492412937-b28074a5d7da") +DANDIYA_IMAGE = IMG("photo-1533174072545-7a4b6ad7a6c3") + +VENUES = [ + { + "slug": "palace-grounds", + "name": "Palace Grounds", + "area": "Vasanth Nagar", + "address": "Palace Grounds, Jayamahal, Bengaluru, Karnataka 560006", + "map_url": "https://maps.google.com/?q=Palace+Grounds+Bengaluru", + "description": "Large open-air lawns suited to daylight Holi editions.", + }, + { + "slug": "ktpo-whitefield", + "name": "KTPO Convention Centre", + "area": "Whitefield", + "address": "KTPO, Whitefield Main Rd, Bengaluru, Karnataka 560066", + "map_url": "https://maps.google.com/?q=KTPO+Whitefield", + "description": "Convention-scale halls and floors for Garba and Dandiya nights.", + }, + { + "slug": "hsr-layout-grounds", + "name": "HSR Layout Club Grounds", + "area": "HSR Layout", + "address": "27th Main Rd, HSR Layout, Bengaluru, Karnataka 560102", + "map_url": "https://maps.google.com/?q=HSR+Layout+Bengaluru", + "description": "Neighbourhood grounds for intimate cultural socials.", + }, +] + +EVENTS = [ + { + "slug": "rang-barse-palace-grounds-2026", + "title": "Rang Barse — Palace Grounds", + "category": EventType.holi, + "venue_slug": "palace-grounds", + "starts_at": datetime(2026, 3, 9, 10, 0, tzinfo=timezone.utc), + "ends_at": datetime(2026, 3, 9, 18, 0, tzinfo=timezone.utc), + "is_featured": True, + "cover": HOLI_IMAGE, + "cover_alt": "Crowd celebrating Holi with colour in the air", + "description": "Daylight Holi with live dhol, curated gulal zones, and family-friendly lawns.", + "media": [(HOLI_IMAGE, "Holi colour cloud above dancers", 0)], + "tickets": [ + ("General admission", "Full-day access with colour kit", 149900, 5000), + ("Shade deck", "Reserved seating and meal credit", 299900, 400), + ], + }, + { + "slug": "navratri-nights-ktpo", + "title": "Navratri Nights", + "category": EventType.garba, + "venue_slug": "ktpo-whitefield", + "starts_at": datetime(2026, 10, 3, 19, 0, tzinfo=timezone.utc), + "ends_at": datetime(2026, 10, 4, 1, 0, tzinfo=timezone.utc), + "is_featured": True, + "cover": GARBA_IMAGE, + "cover_alt": "Women in traditional attire performing garba under evening lights", + "description": "Whitefield Garba with live folk vocals, circular floor, and midnight raas.", + "media": [ + (GARBA_IMAGE, "Garba dancers in traditional dress", 0), + (GARBA_IMAGE_ALT, "Garba dancers in coordinated circle", 1), + ], + "tickets": [ + ("Dance floor", "Standing access to main circle", 99900, 3000), + ("Balcony lounge", "Elevated seating", 189900, 250), + ], + }, + { + "slug": "hsr-dandiya-social-2026", + "title": "HSR Dandiya Social", + "category": EventType.dandiya, + "venue_slug": "hsr-layout-grounds", + "starts_at": datetime(2026, 10, 11, 20, 0, tzinfo=timezone.utc), + "ends_at": datetime(2026, 10, 12, 1, 0, tzinfo=timezone.utc), + "is_featured": False, + "cover": DANDIYA_IMAGE, + "cover_alt": "Evening lanterns at a cultural celebration", + "description": "South Bengaluru Dandiya social with resident callers and midnight chai.", + "media": [(DANDIYA_IMAGE, "Festival lights over the dance floor", 0)], + "tickets": [("Early bird", "Full night access", 79900, 800)], + }, +] + + +def upsert_venue(db, data: dict) -> Venue: + venue = db.scalar(select(Venue).where(Venue.slug == data["slug"])) + if venue: + for key, value in data.items(): + setattr(venue, key, value) + return venue + venue = Venue(**data) + db.add(venue) + db.flush() + return venue + + +def seed() -> None: + db = SessionLocal() + try: + venues_by_slug = {} + for row in VENUES: + venues_by_slug[row["slug"]] = upsert_venue(db, row) + + for row in EVENTS: + venue = venues_by_slug[row["venue_slug"]] + event = db.scalar(select(Event).where(Event.slug == row["slug"])) + if not event: + event = Event( + slug=row["slug"], + title=row["title"], + category=row["category"], + status=EventStatus.published, + venue_id=venue.id, + description=row["description"], + starts_at=row["starts_at"], + ends_at=row["ends_at"], + is_featured=row["is_featured"], + cover_image_url=row["cover"], + cover_image_alt=row["cover_alt"], + meta_description=row["description"][:300], + ) + db.add(event) + db.flush() + else: + event.title = row["title"] + event.category = row["category"] + event.status = EventStatus.published + event.venue_id = venue.id + event.description = row["description"] + event.starts_at = row["starts_at"] + event.ends_at = row["ends_at"] + event.is_featured = row["is_featured"] + event.cover_image_url = row["cover"] + event.cover_image_alt = row["cover_alt"] + + if not event.media: + for url, alt, order in row["media"]: + db.add( + EventMedia( + event_id=event.id, + image_url=url, + alt_text=alt, + sort_order=order, + ) + ) + + if not event.ticket_types: + for idx, (name, desc, price_paise, qty) in enumerate(row["tickets"]): + db.add( + TicketType( + event_id=event.id, + name=name, + description=desc, + price_paise=price_paise, + quantity_total=qty, + status=TicketTypeStatus.active, + sort_order=idx, + ) + ) + + db.commit() + print("Seeded catalog: venues, Holi, Garba, Dandiya events.") + finally: + db.close() + + +if __name__ == "__main__": + seed() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..dde0987 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,4 @@ +import os + +# Tests share one TestClient IP; disable in-process rate limits during pytest. +os.environ.setdefault("DESI_CLANS_DISABLE_RATE_LIMIT", "1") diff --git a/backend/tests/helpers.py b/backend/tests/helpers.py new file mode 100644 index 0000000..8fd5076 --- /dev/null +++ b/backend/tests/helpers.py @@ -0,0 +1,56 @@ +import uuid + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def register_user() -> tuple[str, str]: + email = f"buyer-{uuid.uuid4().hex}@example.com" + password = "securepass1" + response = client.post( + "/api/auth/register", + json={"email": email, "password": password, "full_name": "Buyer"}, + ) + assert response.status_code == 201 + token = response.json()["access_token"] + return email, token + + +def auth_headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def first_published_event() -> dict: + response = client.get("/api/events", params={"upcoming": "false"}) + assert response.status_code == 200 + events = response.json() + assert events, "Seed catalog required for order tests" + return events[0] + + +def pick_tier(tiers: list[dict], quantity: int = 1) -> dict | None: + return next( + (t for t in tiers if t["status"] == "active" and t["remaining_quantity"] >= quantity), + None, + ) + + +def create_pending_order(): + import pytest + + _, token = register_user() + event = first_published_event() + tiers = client.get(f"/api/events/{event['id']}/tickets").json() + tier = pick_tier(tiers, 1) + if not tier: + pytest.skip("No ticket inventory available for test") + response = client.post( + "/api/orders", + headers={**auth_headers(token), "Idempotency-Key": uuid.uuid4().hex}, + json={"event_id": event["id"], "items": [{"ticket_type_id": tier["id"], "quantity": 1}]}, + ) + assert response.status_code == 201 + return token, response.json() diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py new file mode 100644 index 0000000..456eb3a --- /dev/null +++ b/backend/tests/test_admin_api.py @@ -0,0 +1,50 @@ +import uuid + +from tests.helpers import auth_headers, client, register_user + + +def _admin_token() -> str: + from app.core.config import get_settings + + settings = get_settings() + response = client.post( + "/api/admin/auth/login", + json={"email": settings.admin_email, "password": settings.admin_password}, + ) + if response.status_code != 200: + raise RuntimeError(f"Admin login failed: {response.text} — run scripts/seed_admin.py") + return response.json()["access_token"] + + +def test_attendee_cannot_access_admin_dashboard() -> None: + _, token = register_user() + response = client.get("/api/admin/dashboard/metrics", headers=auth_headers(token)) + assert response.status_code == 401 + + +def test_admin_dashboard_metrics() -> None: + token = _admin_token() + response = client.get("/api/admin/dashboard/metrics", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + body = response.json() + assert "total_revenue_paise" in body + assert "tickets_sold" in body + assert "upcoming_events" in body + + +def test_admin_lists_catalog() -> None: + token = _admin_token() + headers = {"Authorization": f"Bearer {token}"} + assert client.get("/api/admin/events", headers=headers).status_code == 200 + assert client.get("/api/admin/venues", headers=headers).status_code == 200 + assert client.get("/api/admin/orders", headers=headers).status_code == 200 + assert client.get("/api/admin/payments", headers=headers).status_code == 200 + assert client.get("/api/admin/stats/events", headers=headers).status_code == 200 + + +def test_admin_invalid_login() -> None: + response = client.post( + "/api/admin/auth/login", + json={"email": f"nope-{uuid.uuid4().hex}@example.com", "password": "wrongpass"}, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..3ab87dc --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,95 @@ +import uuid + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def _unique_email() -> str: + return f"test-{uuid.uuid4().hex}@example.com" + + +def _register(email: str | None = None, password: str = "securepass1") -> tuple[str, dict]: + address = email or _unique_email() + response = client.post( + "/api/auth/register", + json={"email": address, "password": password, "full_name": "Test User"}, + ) + return address, response + + +def test_register_login_and_me() -> None: + email, register_response = _register() + assert register_response.status_code == 201 + token = register_response.json()["access_token"] + assert token + + login_response = client.post("/api/auth/login", json={"email": email, "password": "securepass1"}) + assert login_response.status_code == 200 + assert login_response.json()["token_type"] == "bearer" + + me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me_response.status_code == 200 + body = me_response.json() + assert body["email"] == email + assert body["full_name"] == "Test User" + assert body["is_active"] is True + assert "hashed_password" not in body + + +def test_register_duplicate_email() -> None: + email, first = _register() + assert first.status_code == 201 + second = client.post( + "/api/auth/register", + json={"email": email, "password": "securepass1"}, + ) + assert second.status_code == 409 + + +def test_register_password_too_short() -> None: + response = client.post( + "/api/auth/register", + json={"email": _unique_email(), "password": "short"}, + ) + assert response.status_code == 422 + + +def test_login_invalid_credentials() -> None: + email, register_response = _register() + assert register_response.status_code == 201 + + wrong_password = client.post("/api/auth/login", json={"email": email, "password": "wrongpassword"}) + assert wrong_password.status_code == 401 + + unknown = client.post("/api/auth/login", json={"email": "nobody@example.com", "password": "securepass1"}) + assert unknown.status_code == 401 + + +def test_me_requires_auth() -> None: + response = client.get("/api/auth/me") + assert response.status_code == 401 + + +def test_me_rejects_invalid_token() -> None: + response = client.get("/api/auth/me", headers={"Authorization": "Bearer not-a-valid-jwt"}) + assert response.status_code == 401 + + +def test_passwords_are_hashed_in_database() -> None: + from app.db.session import SessionLocal + from app.services.users import get_user_by_email + + email, register_response = _register(password="anotherpass9") + assert register_response.status_code == 201 + + db = SessionLocal() + try: + user = get_user_by_email(db, email) + assert user is not None + assert user.hashed_password != "anotherpass9" + assert user.hashed_password.startswith("$2") + finally: + db.close() diff --git a/backend/tests/test_catalog_api.py b/backend/tests/test_catalog_api.py new file mode 100644 index 0000000..b99c381 --- /dev/null +++ b/backend/tests/test_catalog_api.py @@ -0,0 +1,64 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_list_events_published() -> None: + response = client.get("/api/events") + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + if body: + assert "slug" in body[0] + assert "venue" in body[0] + assert body[0]["status"] == "published" + + +def test_list_events_filter_category() -> None: + response = client.get("/api/events", params={"category": "garba", "upcoming": "false"}) + assert response.status_code == 200 + for item in response.json(): + assert item["category"] == "garba" + + +def test_get_event_by_slug() -> None: + listing = client.get("/api/events", params={"upcoming": "false"}) + assert listing.status_code == 200 + if not listing.json(): + return + slug = listing.json()[0]["slug"] + detail = client.get(f"/api/events/{slug}") + assert detail.status_code == 200 + data = detail.json() + assert data["slug"] == slug + assert "ticket_types" in data + assert "media" in data + + +def test_get_event_not_found() -> None: + response = client.get("/api/events/does-not-exist-slug") + assert response.status_code == 404 + + +def test_list_venues() -> None: + response = client.get("/api/venues") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +def test_get_venue_by_id() -> None: + listing = client.get("/api/venues") + assert listing.status_code == 200 + if not listing.json(): + return + venue_id = listing.json()[0]["id"] + response = client.get(f"/api/venues/{venue_id}") + assert response.status_code == 200 + assert response.json()["id"] == venue_id + + +def test_get_venue_not_found() -> None: + response = client.get("/api/venues/999999") + assert response.status_code == 404 diff --git a/backend/tests/test_digital_tickets.py b/backend/tests/test_digital_tickets.py new file mode 100644 index 0000000..81de29f --- /dev/null +++ b/backend/tests/test_digital_tickets.py @@ -0,0 +1,61 @@ +import json + +from tests.helpers import auth_headers, client, create_pending_order +from app.core.config import get_settings +from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway + + +def _pay_and_get_ticket(): + token, checkout = create_pending_order() + gateway = get_payment_gateway() + assert isinstance(gateway, MockPaymentGateway) + payment = checkout["payment"] + payment_id = f"mock_pay_{checkout['id']}" + signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id) + client.post( + f"/api/orders/{checkout['id']}/payment/verify", + headers=auth_headers(token), + json={ + "provider_order_id": payment["provider_order_id"], + "provider_payment_id": payment_id, + "signature": signature, + }, + ) + tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json() + ticket_id = tickets[0]["id"] + return token, ticket_id + + +def test_ticket_detail_includes_qr() -> None: + token, ticket_id = _pay_and_get_ticket() + response = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token)) + assert response.status_code == 200 + body = response.json() + assert body["qr_image_base64"] + assert body["event_title"] + assert body["venue_name"] + assert body["ticket_type_name"] + assert body["code"].startswith("DC-") + payload = json.loads(body["qr_payload"]) + assert payload["token"] == body["qr_token"] + + +def test_verify_marks_ticket_used_and_prevents_reuse() -> None: + token, ticket_id = _pay_and_get_ticket() + detail = client.get(f"/api/tickets/{ticket_id}", headers=auth_headers(token)).json() + qr_token = detail["qr_token"] + + settings = get_settings() + headers = {} + if settings.ticket_verify_api_key: + headers["X-Check-In-Key"] = settings.ticket_verify_api_key + + first = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers) + assert first.status_code == 200 + assert first.json()["valid"] is True + assert first.json()["status"] == "used" + + second = client.post("/api/tickets/verify", json={"qr_token": qr_token}, headers=headers) + assert second.status_code == 200 + assert second.json()["valid"] is False + assert "already used" in second.json()["message"].lower() diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..36bccfe --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,19 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_endpoint() -> None: + response = client.get("/api/health") + assert response.status_code in (200, 503) + body = response.json() + assert body["service"] == "desi-clans-api" + assert "database" in body + assert body["status"] in ("ok", "degraded") + + if response.status_code == 200: + assert body["database"] == "connected" + else: + assert body["database"] == "unavailable" diff --git a/backend/tests/test_orders.py b/backend/tests/test_orders.py new file mode 100644 index 0000000..6ad7353 --- /dev/null +++ b/backend/tests/test_orders.py @@ -0,0 +1,174 @@ +import uuid +from concurrent.futures import ThreadPoolExecutor + +import pytest +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.main import app +from app.models.catalog import TicketType +from app.models.enums import OrderStatus, TicketTypeStatus +from app.models.user import User +from app.schemas.orders import OrderCreate, OrderLineCreate +from app.services.orders import InsufficientInventoryError, create_order +from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway +from tests.helpers import ( + auth_headers, + client, + create_pending_order, + first_published_event, + pick_tier, + register_user, +) + + +def _complete_mock_payment(token: str, checkout: dict) -> None: + gateway = get_payment_gateway() + assert isinstance(gateway, MockPaymentGateway) + payment = checkout["payment"] + payment_id = f"mock_pay_{checkout['id']}" + signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id) + response = client.post( + f"/api/orders/{checkout['id']}/payment/verify", + headers=auth_headers(token), + json={ + "provider_order_id": payment["provider_order_id"], + "provider_payment_id": payment_id, + "signature": signature, + }, + ) + assert response.status_code == 200 + + +def test_list_event_tickets() -> None: + event = first_published_event() + response = client.get(f"/api/events/{event['id']}/tickets") + assert response.status_code == 200 + tiers = response.json() + assert tiers + assert tiers[0]["event_id"] == event["id"] + assert "remaining_quantity" in tiers[0] + assert "price_paise" in tiers[0] + + +def test_create_order_pending_until_verified() -> None: + token, checkout = create_pending_order() + assert checkout["status"] == OrderStatus.pending.value + assert checkout["payment"]["amount_paise"] == checkout["total_paise"] + assert checkout["payment"]["provider_order_id"] + + tickets_before = client.get("/api/tickets/my", headers=auth_headers(token)).json() + assert not any(t["order_id"] == checkout["id"] for t in tickets_before) + + _complete_mock_payment(token, checkout) + + my_tickets = client.get("/api/tickets/my", headers=auth_headers(token)) + assert my_tickets.status_code == 200 + tickets = my_tickets.json() + assert any(t["order_id"] == checkout["id"] for t in tickets) + assert all(t["code"].startswith("DC-") for t in tickets) + + +def test_order_requires_authentication() -> None: + event = first_published_event() + response = client.post( + "/api/orders", + json={"event_id": event["id"], "items": [{"ticket_type_id": 1, "quantity": 1}]}, + ) + assert response.status_code == 401 + + +def test_cannot_oversell_inventory() -> None: + _, token = register_user() + event = first_published_event() + tiers = client.get(f"/api/events/{event['id']}/tickets").json() + tier = pick_tier(tiers, 1) + if not tier: + pytest.skip("No ticket inventory available for test") + if tier["remaining_quantity"] >= 20: + pytest.skip("Quantity cap prevents oversell test for this tier") + + response = client.post( + "/api/orders", + headers=auth_headers(token), + json={ + "event_id": event["id"], + "items": [{"ticket_type_id": tier["id"], "quantity": tier["remaining_quantity"] + 1}], + }, + ) + assert response.status_code == 409 + + +def test_server_calculates_total_not_client() -> None: + token, checkout = create_pending_order() + tiers = client.get(f"/api/events/{checkout['event_id']}/tickets").json() + tier = next(t for t in tiers if t["id"] == checkout["items"][0]["ticket_type_id"]) + qty = checkout["items"][0]["quantity"] + assert checkout["total_paise"] == tier["price_paise"] * qty + + +def test_concurrent_purchases_do_not_oversell() -> None: + event = first_published_event() + db = SessionLocal() + try: + tier = db.scalar( + select(TicketType).where(TicketType.event_id == event["id"]).order_by(TicketType.id.asc()) + ) + assert tier is not None + tier.quantity_sold = max(tier.quantity_total - 1, 0) + tier.quantity_total = tier.quantity_sold + 1 + tier.status = TicketTypeStatus.active + db.commit() + ticket_type_id = tier.id + event_id = event["id"] + finally: + db.close() + + user_ids: list[int] = [] + for _ in range(2): + email = f"race-{uuid.uuid4().hex}@example.com" + reg = client.post( + "/api/auth/register", + json={"email": email, "password": "securepass1"}, + ) + assert reg.status_code == 201 + db = SessionLocal() + try: + user = db.scalar(select(User).where(User.email == email)) + assert user is not None + user_ids.append(user.id) + finally: + db.close() + + def _buy(user_id: int) -> str: + session = SessionLocal() + try: + user = session.get(User, user_id) + create_order( + session, + user, + OrderCreate( + event_id=event_id, + items=[OrderLineCreate(ticket_type_id=ticket_type_id, quantity=1)], + ), + ) + return "success" + except InsufficientInventoryError: + session.rollback() + return "conflict" + finally: + session.close() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(_buy, user_ids)) + + assert results.count("success") == 1 + assert results.count("conflict") == 1 + + db = SessionLocal() + try: + refreshed = db.get(TicketType, ticket_type_id) + assert refreshed is not None + assert refreshed.quantity_sold == refreshed.quantity_total + finally: + db.close() diff --git a/backend/tests/test_payments.py b/backend/tests/test_payments.py new file mode 100644 index 0000000..084e2c9 --- /dev/null +++ b/backend/tests/test_payments.py @@ -0,0 +1,97 @@ +import hashlib +import hmac +import json +from app.core.config import get_settings +from app.services.payments.gateway import MockPaymentGateway, get_payment_gateway +from tests.helpers import auth_headers, client, create_pending_order + + +def _mock_signature(body: bytes) -> str: + secret = get_settings().payment_mock_secret + return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +def test_verify_payment_issues_tickets() -> None: + token, checkout = create_pending_order() + assert checkout["status"] == "pending" + payment = checkout["payment"] + assert payment["provider"] == "mock" + + gateway = get_payment_gateway() + assert isinstance(gateway, MockPaymentGateway) + payment_id = f"mock_pay_{checkout['id']}" + signature = gateway.build_mock_checkout_signature(payment["provider_order_id"], payment_id) + + verify = client.post( + f"/api/orders/{checkout['id']}/payment/verify", + headers=auth_headers(token), + json={ + "provider_order_id": payment["provider_order_id"], + "provider_payment_id": payment_id, + "signature": signature, + }, + ) + assert verify.status_code == 200 + assert verify.json()["status"] == "paid" + + tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json() + assert any(t["order_id"] == checkout["id"] for t in tickets) + + +def test_rejects_invalid_signature() -> None: + token, checkout = create_pending_order() + payment = checkout["payment"] + verify = client.post( + f"/api/orders/{checkout['id']}/payment/verify", + headers=auth_headers(token), + json={ + "provider_order_id": payment["provider_order_id"], + "provider_payment_id": "mock_pay_bad", + "signature": "not-valid", + }, + ) + assert verify.status_code == 400 + + +def test_webhook_idempotent() -> None: + token, checkout = create_pending_order() + payment = checkout["payment"] + payment_id = f"mock_pay_wh_{checkout['id']}" + import uuid + + event_id = f"evt_duplicate_{uuid.uuid4().hex}" + payload = { + "id": event_id, + "event": "payment.captured", + "payload": { + "payment": { + "entity": { + "id": payment_id, + "order_id": payment["provider_order_id"], + "amount": checkout["total_paise"], + "currency": "INR", + } + } + }, + } + body = json.dumps(payload).encode() + signature = _mock_signature(body) + headers = {"X-Mock-Signature": signature} + + first = client.post("/api/webhooks/mock", content=body, headers=headers) + assert first.status_code == 200 + assert first.json()["status"] == "paid" + + second = client.post("/api/webhooks/mock", content=body, headers=headers) + assert second.status_code == 200 + assert second.json()["status"] == "duplicate_ignored" + + tickets = client.get("/api/tickets/my", headers=auth_headers(token)).json() + assert len([t for t in tickets if t["order_id"] == checkout["id"]]) == 1 + + +def test_cancel_pending_order_releases_inventory() -> None: + token, checkout = create_pending_order() + cancel = client.post(f"/api/orders/{checkout['id']}/cancel", headers=auth_headers(token)) + assert cancel.status_code == 200 + assert cancel.json()["status"] == "cancelled" diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..0678e63 --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,55 @@ +from datetime import timedelta + +from jose import jwt + +from app.core.config import Settings +from app.core.security import ( + ALGORITHM, + TOKEN_TYPE_USER, + create_user_access_token, + decode_user_access_token, + hash_password, + verify_password, +) + + +def test_password_hashing_roundtrip() -> None: + hashed = hash_password("my-secret-password") + assert hashed != "my-secret-password" + assert verify_password("my-secret-password", hashed) + assert not verify_password("wrong", hashed) + + +def test_jwt_expiration_claim() -> None: + settings = Settings( + environment="development", + database_url="postgresql://localhost/test", + secret_key="unit-test-secret-key-at-least-32-chars", + access_token_expire_minutes=30, + ) + token = create_user_access_token(42, settings) + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + assert payload["sub"] == "42" + assert payload["type"] == TOKEN_TYPE_USER + assert "exp" in payload + + user_id = decode_user_access_token(token, settings) + assert user_id == 42 + + +def test_jwt_rejects_wrong_type() -> None: + settings = Settings( + environment="development", + database_url="postgresql://localhost/test", + secret_key="unit-test-secret-key-at-least-32-chars", + access_token_expire_minutes=30, + ) + from datetime import datetime, timezone + + expire = datetime.now(timezone.utc) + timedelta(minutes=5) + admin_style = jwt.encode( + {"sub": "admin@example.com", "exp": expire, "type": "admin"}, + settings.secret_key, + algorithm=ALGORITHM, + ) + assert decode_user_access_token(admin_style, settings) is None diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py new file mode 100644 index 0000000..1c09672 --- /dev/null +++ b/backend/tests/test_security_hardening.py @@ -0,0 +1,50 @@ +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from app.core.config import Settings +from tests.helpers import client + + +def _base_settings_kwargs(**overrides): + return { + "environment": "development", + "database_url": "postgresql://localhost/test", + "secret_key": "unit-test-secret-key-at-least-32-chars", + **overrides, + } + + +def test_production_requires_gate_key_and_razorpay() -> None: + with pytest.raises(ValidationError): + Settings( + **_base_settings_kwargs( + environment="production", + payment_provider="mock", + ticket_verify_api_key="short", + ) + ) + + settings = Settings( + **_base_settings_kwargs( + environment="production", + payment_provider="razorpay", + ticket_verify_api_key="gate-key-min-16-chars", + razorpay_key_id="rzp_test", + razorpay_key_secret="secret", + razorpay_webhook_secret="whsec", + admin_password="not-the-default", + cors_origins="https://desiclans.in", + ) + ) + assert settings.environment == "production" + + +@patch("app.api.routes.webhooks.get_settings") +def test_mock_webhook_disabled_outside_development(mock_get_settings) -> None: + mock_get_settings.return_value = Settings( + **_base_settings_kwargs(environment="staging"), + ) + response = client.post("/api/webhooks/mock", content=b"{}", headers={"X-Mock-Signature": "x"}) + assert response.status_code == 404 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4fa1ea0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: desiclans + POSTGRES_PASSWORD: desiclans_dev + POSTGRES_DB: desiclans + ports: + - "5432:5432" + volumes: + - desiclans_pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U desiclans -d desiclans"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + desiclans_pg_data: diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md new file mode 100644 index 0000000..630473e --- /dev/null +++ b/docs/API_SPEC.md @@ -0,0 +1,586 @@ +# API Specification — Desi Clans + +**Version:** 1.0 +**Base URL:** `https://api.desiclans.com` (prod) / `http://localhost:8000` (dev) +**Format:** JSON unless noted +**Auth:** `Authorization: Bearer ` for protected routes + +--- + +## 1. Conventions + +| Item | Rule | +|------|------| +| Errors | `{ "detail": "message" }` or validation array from FastAPI | +| Dates | ISO 8601 UTC in JSON | +| Pagination | `?limit=20&offset=0` where added later; v1 lists unbounded with sane caps in service | +| IDs | Integer primary keys in v1 | +| Slugs | Lowercase, hyphenated, unique per edition | + +--- + +## 2. Platform — `/api` + +### `GET /health` + +Liveness and PostgreSQL connectivity check. + +**Response 200** — database reachable + +```json +{ + "status": "ok", + "service": "desi-clans-api", + "database": "connected" +} +``` + +**Response 503** — database unreachable + +```json +{ + "status": "degraded", + "service": "desi-clans-api", + "database": "unavailable" +} +``` + +--- + +### `GET /events` + +List catalog events (published by default). + +**Query** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `category` | `holi` \| `garba` \| `dandiya` \| `bhajan_clubbing` \| `special` | — | Filter by category | +| `status` | `draft` \| `published` \| `cancelled` | `published` | Filter by status | +| `upcoming` | boolean | `true` | If true, only `starts_at >= now` (UTC) | + +**Response 200** — `EventSummary[]` + +```json +[ + { + "id": 1, + "slug": "rang-barse-palace-grounds-2026", + "title": "Rang Barse — Palace Grounds", + "category": "holi", + "status": "published", + "starts_at": "2026-03-09T10:00:00Z", + "ends_at": "2026-03-09T18:00:00Z", + "is_featured": true, + "cover_image_url": "https://…", + "cover_image_alt": "…", + "venue": { + "id": 1, + "slug": "palace-grounds", + "name": "Palace Grounds", + "area": "Vasanth Nagar", + "city": "Bengaluru", + "address": "…", + "map_url": "https://…", + "description": "…" + } + } +] +``` + +--- + +### `GET /events/{slug}` + +**Response 200** — `EventDetail` (includes `description`, `meta_description`, `media[]`, `ticket_types[]`) + +**Response 404** — unknown slug + +--- + +### `GET /events/{id}/tickets` + +List purchasable ticket types for a **published** event (numeric event id). + +**Response 200** — `EventTicketTypePublic[]` + +```json +[ + { + "id": 1, + "event_id": 1, + "name": "General admission", + "description": "Full-day access", + "price_paise": 149900, + "status": "active", + "sort_order": 0, + "remaining_quantity": 42 + } +] +``` + +**Response 404** — unknown or unpublished event + +--- + +### `GET /venues` + +**Response 200** — `VenuePublic[]` + +--- + +### `GET /venues/{id}` + +**Response 200** — `VenuePublic` +**Response 404** — unknown id + +--- + +## 3. Public — `/api/public` (legacy, planned) + +Legacy public routes will mount under this prefix when business routers are enabled. + +--- + +### `GET /config` (not yet mounted) + +Site-wide config: platform links, feature flags (future). + +**Response 200** — `SiteConfigPublic` + +```json +{ + "site_name": "Desi Clans", + "city": "Bengaluru", + "links": [ + { "id": 1, "kind": "instagram", "label": "Instagram", "url": "https://...", "sort_order": 0 } + ] +} +``` + +--- + +### `GET /editions` + +List published editions. + +**Query** + +| Param | Type | Description | +|-------|------|-------------| +| `event_type` | string | Optional enum filter | +| `featured` | bool | Optional | + +**Response 200** — `EventEditionPublic[]` + +--- + +### `GET /editions/{slug}` + +Single published edition. + +**Response 200** — `EventEditionPublic` +**Response 404** — not found or unpublished + +**`EventEditionPublic` (shape)** + +```json +{ + "id": 1, + "title": "Holi at Palace Grounds", + "slug": "holi-palace-grounds-2026", + "event_type": "holi", + "city": "Bengaluru", + "venue_name": "Palace Grounds", + "event_date": "2026-03-10T10:00:00Z", + "description": "...", + "is_featured": true, + "cover_image_url": "/media/...", + "cover_thumb_url": "/media/.../thumb", + "tickets": { + "bookmyshow": "https://...", + "district": null, + "instagram": null + }, + "meta_description": "...", + "media": [ + { "id": 1, "url": "...", "thumb_url": "...", "caption": null, "sort_order": 0 } + ] +} +``` + +*Future:* `ticketing_mode`, `tiers[]` when native ticketing is enabled. + +--- + +### `GET /go` + +Outbound redirect with click tracking. + +**Query** + +| Param | Required | Description | +|-------|----------|-------------| +| `kind` | yes | e.g. `bookmyshow`, `district`, `instagram` | +| `edition_id` | no | Edition context | + +**Response 302** → destination URL + +--- + +### `POST /subscribe` + +**Body** — `SubscribeRequest` + +```json +{ "email": "user@example.com", "neighborhood": "HSR Layout" } +``` + +**Response 201** — created +**Response 409** — email already subscribed + +--- + +### `GET /editions/{slug}/calendar.ics` + +**Response 200** — `text/calendar` body + +--- + +## 3. Share (crawler HTML) + +### `GET /share/events/{slug}` + +Also available under public router duplicate path per deployment. + +**Response 200** — HTML with Open Graph tags (no auth). + +--- + +## 4. Auth — `/api/auth` + +Attendee accounts (bcrypt-hashed passwords, JWT bearer tokens). Admin auth remains separate under `/api/admin` when legacy routers are mounted. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/register` | — | Create account | +| POST | `/login` | — | Issue access token | +| GET | `/me` | Bearer | Current user profile | + +### `POST /register` + +**Body** — `RegisterRequest` + +```json +{ + "email": "guest@example.com", + "password": "minimum-8-chars", + "full_name": "Optional Name" +} +``` + +**Response 201** — `Token` + +**Response 409** — email already registered +**Response 422** — validation error (password < 8 characters, invalid email) + +--- + +### `POST /login` + +**Body** — `LoginRequest` + +```json +{ "email": "guest@example.com", "password": "********" } +``` + +**Response 200** — `Token` + +```json +{ "access_token": "...", "token_type": "bearer" } +``` + +**Response 401** — invalid credentials + +JWT claims: `sub` (user id), `exp`, `type: "user"`. Lifetime from `ACCESS_TOKEN_EXPIRE_MINUTES` (server env). + +--- + +### `GET /me` + +**Headers** — `Authorization: Bearer ` + +**Response 200** — `UserPublic` + +```json +{ + "id": 1, + "email": "guest@example.com", + "full_name": "Optional Name", + "is_active": true, + "created_at": "2026-04-04T12:00:00Z" +} +``` + +**Response 401** — missing, invalid, or expired token + +Protected routes use the `CurrentUser` dependency (`get_current_user` in `app/core/deps.py`). + +--- + +## 5. Orders & tickets — `/api/orders`, `/api/tickets` + +Requires attendee Bearer token. Prices are **always** computed server-side from `ticket_types.price_paise`; the client sends only `ticket_type_id` and `quantity`. + +Checkout reserves inventory while `status: pending`. Tickets are issued only after **verified** payment (checkout signature or webhook). Never trust client-side “payment success” flags. + +**Provider:** Razorpay in production (`PAYMENT_PROVIDER=razorpay`). Local dev uses `mock` (no secrets in React). + +Optional header: `Idempotency-Key` on `POST /orders` (unique per checkout attempt). + +### `POST /orders` + +**Body** — `OrderCreate` + +```json +{ + "event_id": 1, + "items": [{ "ticket_type_id": 2, "quantity": 2 }] +} +``` + +**Response 201** — `OrderCheckoutResponse` (`status: pending`, `payment` block with public `key_id`, `provider_order_id`, `amount_paise`, `expires_at`) + +**Response 400** — invalid event, wrong event/tier pairing, empty cart +**Response 401** — not authenticated +**Response 409** — insufficient inventory + +--- + +### `POST /orders/{id}/payment/verify` + +Server-side verification of Razorpay (or mock) checkout signature. Marks order `paid` and issues tickets. + +**Body** — `PaymentVerifyRequest` + +```json +{ + "provider_order_id": "order_…", + "provider_payment_id": "pay_…", + "signature": "…" +} +``` + +**Response 200** — `OrderPublic` (`status: paid`) +**Response 400** — invalid signature or amount mismatch +**Response 409** — order not pending + +--- + +### `POST /orders/{id}/cancel` + +User abandoned checkout. Releases reserved inventory. **Pending orders only.** + +**Response 200** — `OrderPublic` (`status: cancelled`) + +--- + +### `POST /orders/{id}/payment/mock-complete` + +Development only (`ENVIRONMENT=development`, `PAYMENT_PROVIDER=mock`). Server completes mock payment without client-held secrets. + +--- + +### Webhooks — `/api/webhooks` + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/razorpay` | `X-Razorpay-Signature` | Razorpay events (`payment.captured`, `payment.failed`) | +| POST | `/mock` | `X-Mock-Signature` | HMAC body signature (tests / local) | + +Webhook processing is **idempotent** (`payment_webhook_events.provider_event_id` unique). Duplicate deliveries return `{ "status": "duplicate_ignored" }`. + +**Env (server only):** `RAZORPAY_KEY_SECRET`, `RAZORPAY_WEBHOOK_SECRET` — never expose to the frontend. `RAZORPAY_KEY_ID` is public and returned in checkout payloads for Razorpay.js. + +Pending orders expire after `ORDER_PAYMENT_TIMEOUT_MINUTES` (inventory released, `status: failed`). + +--- + +### `GET /orders/{id}` + +**Response 200** — `OrderPublic` (owner only) +**Response 404** — not found or not your order + +--- + +### `GET /tickets/my` + +**Response 200** — `TicketPublic[]` for the current user + +--- + +### `GET /tickets/{id}` + +Authenticated owner only. Returns full digital pass payload including server-generated QR PNG (base64). + +**Response 200** — `TicketDetailPublic` (event, venue, attendee, ticket type, `code`, `qr_payload`, `qr_image_base64`, `qr_token`) +**Response 404** — not found or not your ticket + +--- + +### `POST /tickets/verify` + +Gate / check-in verification (server-side). Marks ticket `used` atomically — **QR reuse rejected**. + +**Headers:** `X-Check-In-Key` (required in production via `TICKET_VERIFY_API_KEY`) + +**Body** — `TicketVerifyRequest` + +```json +{ "qr_token": "…" } +``` + +**Response 200** — `TicketVerifyResponse` (`valid`, `status`, `message`, `ticket_code`, `event_title`) + +--- + +**`TicketPublic.status` / `TicketDetailPublic.status`:** `valid` | `used` | `cancelled` + +--- + +## 6. Admin — `/api/admin` + +Requires admin JWT. `editor` vs `super_admin` enforced per endpoint. + +### `GET /me` + +Current admin profile. + +**Response 200** — `AdminUserPublic` + +--- + +### Platform links + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| GET | `/links` | editor+ | List active links | +| POST | `/links` | super_admin | Create | +| PATCH | `/links/{link_id}` | super_admin | Update | +| DELETE | `/links/{link_id}` | super_admin | Delete | + +--- + +### Editions + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| GET | `/editions` | editor+ | All editions (incl. draft) | +| POST | `/editions` | editor+ | Create | +| PATCH | `/editions/{edition_id}` | editor+ | Update fields, publish flags | +| POST | `/editions/{edition_id}/cover` | editor+ | Multipart cover upload | +| POST | `/editions/{edition_id}/media` | editor+ | Gallery upload | +| PATCH | `/media/{media_id}` | editor+ | Caption, publish flag | +| POST | `/editions/{edition_id}/media/reorder` | editor+ | Body: ordered media ids | +| DELETE | `/media/{media_id}` | editor+ | Remove asset | + +**`EventEditionAdmin`** extends public fields with `is_published`, `sort_order`, raw URLs, etc. + +--- + +### Users & subscribers + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| GET | `/users` | super_admin | List admins | +| POST | `/users` | super_admin | Create admin | +| GET | `/subscribers` | editor+ | Newsletter list | + +--- + +## 7. Analytics — `/api/admin/analytics` + +### `GET /clicks/summary` + +**Response 200** — `ClickSummaryItem[]` + +```json +[{ "link_kind": "bookmyshow", "count": 42 }] +``` + +### `GET /clicks/recent` + +**Response 200** — `OutboundClickPublic[]` + +--- + +## 8. Future — Check-in & enhancements + +### Catalog — `/api/public/editions/{slug}/tiers` + +**Response** — active tiers with `remaining_quantity` (or sold out flag). + +### Orders (extensions) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/` | User | Create pending order | +| GET | `/` | User | My orders | +| GET | `/{order_id}` | User | Order detail + tickets | +| POST | `/webhooks/payment` | HMAC | Provider callback (no JWT) | + +### Check-in — `/api/check-in` + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/scan` | Staff | Body: `{ "qr": "..." }` → ticket status | + +--- + +## 8. Enums + +### `EventType` + +`holi` | `garba` | `dandiya` | `bhajan_clubbing` | `special` + +### `PlatformKind` + +`bookmyshow` | `district` | `instagram` | `highape` | `youtube` | `whatsapp` | `website` | `other` + +### `AdminRole` + +`super_admin` | `editor` + +### `OrderStatus` + +`pending` | `paid` | `cancelled` | `failed` + +### `TicketStatus` (issued ticket) + +`valid` | `used` | `cancelled` + +--- + +## 9. CORS + +Allowed origins from `CORS_ORIGINS` env (comma-separated). Credentials enabled for admin SPA origin. + +--- + +## 10. Static media + +- **Local dev:** `GET /media/{path}` +- **Prod:** CDN URL built from stored key in `file_path` + +--- + +## 11. Versioning + +Breaking changes require `/api/v2` prefix or negotiated migration. v1 remains unversioned path until scale demands it. + +--- + +## 12. Related + +- [DATABASE.md](./DATABASE.md) +- [ARCHITECTURE.md](./ARCHITECTURE.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..075fd0d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,275 @@ +# Architecture — Desi Clans + +**Version:** 1.0 +**Last updated:** 2026-04-04 + +--- + +## 1. System context + +``` +┌─────────────┐ HTTPS ┌──────────────┐ SQL ┌────────────┐ +│ Browser │ ──────────────► │ FastAPI API │ ◄──────────► │ PostgreSQL │ +│ (React) │ │ + static │ └────────────┘ +└─────────────┘ │ media │ + │ └──────┬───────┘ + │ │ + │ optional ▼ + └──────────────────────► S3 / CDN (prod media) +``` + +- **Monorepo:** `frontend/`, `backend/`, `docs/`. +- **Deployment unit:** API container + static frontend (CDN or same origin) + managed Postgres. +- **No microservices in v1.** One API process, clear router boundaries for future split. + +--- + +## 2. Architectural principles + +1. **API-first content** — Public UI never hardcodes event copy or images. +2. **Thin client** — Business rules (inventory, check-in) live in the backend. +3. **Explicit phases** — Ship discovery + admin before native payments if needed. +4. **Boring tech** — Stack locked to PRD; no extra frameworks without ADR. +5. **Security defaults** — JWT for auth, parameterized SQL via SQLAlchemy, validated Pydantic I/O. + +--- + +## 3. Repository layout (target) + +``` +DESI-CLANS/ +├── frontend/ +│ ├── public/ +│ ├── src/ +│ │ ├── api/ # Axios clients, types mirroring API_SPEC +│ │ ├── components/ # UI primitives + composites +│ │ ├── hooks/ +│ │ ├── layouts/ +│ │ ├── pages/ # Route-level views +│ │ ├── styles/ # Tailwind entry, tokens +│ │ ├── utils/ # seo, dates, formatters +│ │ ├── animations/ # GSAP contexts, ScrollTrigger setup +│ │ ├── App.jsx +│ │ └── main.jsx +│ ├── index.html +│ ├── vite.config.js +│ ├── tailwind.config.js +│ └── package.json +├── backend/ +│ ├── alembic/ +│ ├── app/ +│ │ ├── routers/ # public, auth, admin, share, analytics, orders (future) +│ │ ├── services/ # domain logic +│ │ ├── models.py +│ │ ├── schemas.py +│ │ ├── auth.py +│ │ ├── config.py +│ │ ├── database.py +│ │ └── main.py +│ ├── scripts/ # seed, migrate helpers +│ ├── tests/ +│ ├── Dockerfile +│ └── requirements.txt +├── docs/ +└── README.md +``` + +--- + +## 4. Backend layers + +| Layer | Responsibility | +|-------|----------------| +| **Routers** | HTTP, status codes, dependency injection, no business logic | +| **Services** | Transactions, orchestration (editions, media, tickets, email) | +| **Models** | SQLAlchemy ORM, enums | +| **Schemas** | Pydantic request/response; never expose hashed passwords | + +**Cross-cutting** + +- `config.py` — settings from environment (see `.env.example` when added). +- `auth.py` — JWT create/verify, `get_current_admin`, future `get_current_user`. +- Migrations — Alembic only; avoid `create_all` in production (dev convenience may exist temporarily). + +--- + +## 5. Frontend layers + +| Layer | Responsibility | +|-------|----------------| +| **Pages** | Data loading per route, SEO hooks, layout composition | +| **Components** | Presentational; accept props, no direct Axios in leaf components | +| **api/** | Base Axios instance, interceptors for admin token | +| **animations/** | GSAP timelines registered once; respect `prefers-reduced-motion` | + +**Routing (React Router)** + +- Public: `/`, `/events`, `/events/:slug`, `/subscribe` (optional dedicated page) +- Admin: `/admin/login`, `/admin/*` (protected) +- Account (phase): `/account`, `/account/tickets/:id` +- Check-in (phase): `/admin/check-in` or staff-only route + +**SEO** + +- Vite SPA: use `react-helmet-async` **only if** needed; prefer minimal approach: + - Default meta in `index.html` + - Per-route `document.title` + meta updates in page `useEffect` + - Backend `/share/events/{slug}` HTML for crawlers (already planned in API) + +--- + +## 6. API boundaries + +| Namespace | Auth | Purpose | +|-----------|------|---------| +| `/api/public/*` | None | Read editions, config, subscribe, outbound redirect, calendar | +| `/api/auth/*` | Credentials | Admin login (attendee login added with ticketing) | +| `/api/admin/*` | Admin JWT | CRUD editions, media, links, users, subscribers | +| `/api/admin/analytics/*` | Admin JWT | Click summaries | +| `/share/events/{slug}` | None | OG HTML for social crawlers | +| `/api/orders/*` (future) | User JWT | Checkout, my tickets | +| `/api/check-in/*` (future) | Staff JWT | Validate QR, mark checked in | + +Public reads are cache-friendly (CDN `Cache-Control` on GET editions list with short TTL). + +--- + +## 7. Media pipeline + +1. Admin uploads via multipart → service stores file. +2. **Local:** `upload_dir` served at `/media/...` +3. **Production:** S3 bucket + public or signed URLs; store `file_path` key in DB. +4. Optional thumbnails generated on upload (service layer); expose `thumb_url` in API. + +--- + +## 8. Ticketing architecture (target) + +``` +EventEdition ──< TicketTier + │ +Order ──────────────┼──< OrderLine (tier, qty, unit_price) + │ + └──< Ticket (code, qr_secret, status, checked_in_at) +``` + +**Checkout flow (native)** + +1. Client POST order intent with tier + quantity. +2. Server validates window, inventory; creates `pending` order; returns payment session (phase B). +3. Webhook confirms payment → status `paid` → issue tickets with unique codes. +4. Email service sends QR (PNG or link to ticket page). + +**QR validation** + +- Payload: `ticket_id` + HMAC signature (server secret). +- Scan endpoint: verify signature, edition match, status `issued` → `checked_in`. + +**Outbound mode (per edition flag)** + +- `ticketing_mode: outbound | native` +- Outbound uses existing `bookmyshow_url`, `district_url`, etc., via `/api/public/go` click tracking. + +--- + +## 9. Security + +- Passwords: bcrypt via passlib. +- JWT: HS256, `ACCESS_TOKEN_EXPIRE_MINUTES` configurable. +- CORS: explicit origin list from env. +- Rate limit: add at reverse proxy (nginx/Cloudflare) for public POST subscribe and auth login. +- Admin actions audited via structured logs (user id, action, entity id). + +--- + +## 10. Performance requirements + +| Area | Target | +|------|--------| +| Public GET editions | p95 < 300ms @ warm DB | +| Image delivery | WebP/AVIF where supported; width variants | +| Frontend bundle | Route-based code splitting for admin | +| DB | Indexes on `slug`, `event_type`, `is_published`, `event_date` | +| Animation | GSAP only on hero/key sections; disable heavy ScrollTrigger on mobile low power | + +--- + +## 11. Observability + +- Health: `GET /api/public/health` +- Structured JSON logs (request id, path, status, duration) +- Production: disable OpenAPI UI (`openapi_url = None` when `environment=production`) + +--- + +## 12. Coding conventions + +### Python (backend) + +- Python 3.11+ +- Type hints on public functions +- `ruff` or `flake8` + `black` (pick one formatter in CI) +- Router functions: max ~30 lines; delegate to services +- Enums in `models.py` for DB enums; mirror in Pydantic where needed + +### JavaScript (frontend) + +- React 18 functional components only +- Named exports for components; default export for pages optional +- Tailwind for styling; no CSS-in-JS library +- Axios instance with `baseURL` from `import.meta.env.VITE_API_URL` +- Env vars prefixed `VITE_` only for client-safe values + +### Git + +- Conventional commits encouraged: `feat:`, `fix:`, `docs:` +- One Alembic revision per schema change + +--- + +## 13. Implementation phases + +### Phase 0 — Constitution (current) + +Documentation only; no new application features. + +### Phase 1 — Public experience + admin content + +- Vite + React + Tailwind + Router +- Wire public API: home, list, detail, subscribe, outbound go links +- Admin: login, edition CRUD, media upload +- Share previews + basic SEO +- **Aligns with partial backend already in repo** + +### Phase 2 — Polish & performance + +- GSAP hero + scroll sections with reduced-motion guard +- Image lazy loading, skeleton states +- Accessibility pass (focus, contrast, aria labels) + +### Phase 3 — Native ticketing + +- DB: tiers, orders, tickets +- Attendee auth +- Checkout + payment webhook +- Ticket page + QR generation +- Staff check-in UI + +### Phase 4 — Hardening + +- E2E smoke tests, load test on read paths +- CDN, backup strategy, runbooks + +--- + +## 14. ADR policy + +Significant deviations (new dependency, new service, auth model change) require a short `docs/adr/NNN-title.md` before implementation. + +--- + +## 15. Related documents + +- [API_SPEC.md](./API_SPEC.md) +- [DATABASE.md](./DATABASE.md) +- [DESIGN_SYSTEM.md](./DESIGN_SYSTEM.md) diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..20cf581 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,210 @@ +# Database Schema — Desi Clans + +**Version:** 1.2 +**Engine:** PostgreSQL 15+ +**ORM:** SQLAlchemy 2.x +**Migrations:** Alembic + +--- + +## 1. Overview + +The database supports: + +1. **Catalog (Phase 6)** — `venues`, `events`, `event_media`, `ticket_types` +2. **Legacy content** — `event_editions`, `media_assets` (admin-era tables) +3. **Marketing** — platform links, newsletter subscribers, outbound clicks +4. **Admin** — admin users and roles +5. **Accounts** — attendee `users` (Phase 8) +6. **Ticketing (Phase 9)** — orders, order items, issued tickets + +--- + +## 2. Catalog ERD (implemented) + +``` +users ──< orders ──< order_items ──< tickets + │ │ + │ └──> ticket_types + └──> events ──< event_media + │ + └──< ticket_types +``` + +--- + +## 3. Tables — catalog + +### `venues` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| slug | VARCHAR(120) | UNIQUE, NOT NULL | +| name | VARCHAR(200) | NOT NULL | +| area | VARCHAR(120) | NOT NULL | +| city | VARCHAR(80) | NOT NULL, default Bengaluru | +| address | VARCHAR(512) | NOT NULL | +| map_url | VARCHAR(512) | NULL | +| description | TEXT | NULL | +| created_at | TIMESTAMPTZ | DEFAULT now() | +| updated_at | TIMESTAMPTZ | DEFAULT now() | + +--- + +### `events` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| slug | VARCHAR(200) | UNIQUE, NOT NULL | +| title | VARCHAR(240) | NOT NULL | +| category | eventtype ENUM | NOT NULL, INDEX | +| status | eventstatus ENUM | NOT NULL, default draft, INDEX | +| venue_id | INT | FK → venues.id RESTRICT, INDEX | +| description | TEXT | NULL | +| starts_at | TIMESTAMPTZ | NOT NULL, INDEX | +| ends_at | TIMESTAMPTZ | NULL | +| is_featured | BOOLEAN | default false | +| cover_image_url | VARCHAR(1024) | NULL | +| cover_image_alt | VARCHAR(300) | NULL | +| meta_description | VARCHAR(320) | NULL | +| created_at | TIMESTAMPTZ | DEFAULT now() | +| updated_at | TIMESTAMPTZ | DEFAULT now() | + +**Indexes:** `(status, starts_at)`, `(category, status)` + +**`eventstatus`:** `draft`, `published`, `cancelled` + +--- + +### `event_media` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| event_id | INT | FK → events.id CASCADE, INDEX | +| image_url | VARCHAR(1024) | NOT NULL | +| alt_text | VARCHAR(300) | NULL | +| sort_order | INT | default 0 | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +--- + +### `ticket_types` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| event_id | INT | FK → events.id CASCADE, INDEX | +| name | VARCHAR(120) | NOT NULL | +| description | VARCHAR(512) | NULL | +| price_paise | INT | NOT NULL | +| quantity_total | INT | NOT NULL | +| quantity_sold | INT | default 0 | +| status | tickettypestatus ENUM | default active | +| sort_order | INT | default 0 | +| created_at | TIMESTAMPTZ | DEFAULT now() | +| updated_at | TIMESTAMPTZ | DEFAULT now() | + +**`tickettypestatus`:** `active`, `sold_out`, `inactive` + +**Check:** `quantity_sold <= quantity_total` (migration `20260404_0007`) + +Inventory updates use row-level locks (`SELECT … FOR UPDATE`) during checkout. + +--- + +### `users` (attendees) + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| email | VARCHAR(255) | UNIQUE, NOT NULL | +| hashed_password | VARCHAR(255) | NOT NULL (bcrypt) | +| full_name | VARCHAR(120) | NULL | +| is_active | BOOLEAN | default true | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +--- + +### `orders` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| user_id | INT | FK → users.id RESTRICT, INDEX | +| event_id | INT | FK → events.id RESTRICT, INDEX | +| status | orderstatus ENUM | NOT NULL, INDEX | +| total_paise | INT | NOT NULL (server-calculated) | +| currency | VARCHAR(3) | default INR | +| payment_reference | VARCHAR(64) | NULL (mock `mock_*` until gateway) | +| paid_at | TIMESTAMPTZ | NULL | +| created_at | TIMESTAMPTZ | DEFAULT now() | +| updated_at | TIMESTAMPTZ | DEFAULT now() | + +**`orderstatus`:** `pending`, `paid`, `cancelled`, `failed` +Checkout currently completes in one transaction with mock payment → `paid`. + +--- + +### `order_items` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| order_id | INT | FK → orders.id CASCADE, INDEX | +| ticket_type_id | INT | FK → ticket_types.id RESTRICT, INDEX | +| quantity | INT | NOT NULL, > 0 | +| unit_price_paise | INT | NOT NULL (snapshot at purchase) | +| line_total_paise | INT | NOT NULL | + +--- + +### `tickets` (issued passes) + +| Column | Type | Constraints | +|--------|------|-------------| +| id | SERIAL | PK | +| code | VARCHAR(32) | UNIQUE, NOT NULL (e.g. `DC-…`) | +| order_id | INT | FK → orders.id CASCADE, INDEX | +| order_item_id | INT | FK → order_items.id CASCADE, INDEX | +| ticket_type_id | INT | FK → ticket_types.id RESTRICT, INDEX | +| event_id | INT | FK → events.id RESTRICT, INDEX | +| user_id | INT | FK → users.id RESTRICT, INDEX | +| status | ticketstatus ENUM | NOT NULL, INDEX | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +**`ticketstatus`:** `valid`, `cancelled`, `redeemed` +**Index:** `(user_id, status)` + +--- + +## 4. Seed data + +```bash +python scripts/seed_catalog.py +``` + +Creates Bengaluru venues (Palace Grounds, KTPO Whitefield, HSR Layout) and published events for **Holi**, **Garba**, and **Dandiya** with media URLs and ticket tiers. + +--- + +## 5. Legacy tables + +See prior revisions for `event_editions`, `media_assets`, `admin_users`, `platform_links`, `newsletter_subscribers`, `outbound_clicks`, `alert_dispatches`. + +--- + +## 6. Migration policy + +- Revision `20260404_0005` adds catalog tables. +- Revision `20260404_0006` adds attendee `users`. +- Revision `20260404_0007` adds `orders`, `order_items`, `tickets`. +- If the DB was created via `create_all` before Alembic history existed, run `alembic stamp 20260404_0004` once, then `alembic upgrade head`. + +--- + +## 7. Related + +- [API_SPEC.md](./API_SPEC.md) diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 0000000..07ec6b7 --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,188 @@ +# Design System — Desi Clans + +**Version:** 1.0 +**Last updated:** 2026-04-04 + +--- + +## 1. Brand intent + +Desi Clans should read as **premium editorial culture**, not a discount ticket site. + +- **Cinematic:** Full-bleed imagery, deliberate negative space, restrained color. +- **Warm, not loud:** Celebratory without carnival clutter. +- **Bengaluru-rooted:** Urban sophistication + festival energy. +- **Trust:** Clear dates, venues, and pricing; no dark patterns on checkout. + +--- + +## 2. Design principles + +1. **Content leads** — Photography and event title dominate; UI chrome stays quiet. +2. **Mobile first** — Design at 390px width; scale up with fluid typography. +3. **Motion with purpose** — GSAP for hero reveals and section entrances; never block interaction. +4. **Accessible by default** — Color is not the only signal; focus states are always visible. +5. **Performance is UX** — Prefer CSS transforms for animation; lazy-load below-fold media. + +--- + +## 3. Color (Tailwind tokens) + +Define in `tailwind.config.js` under `theme.extend.colors`: + +| Token | Role | Suggested value | Usage | +|-------|------|-----------------|--------| +| `ink` | Primary text | `#0F0E0C` | Headlines, body | +| `ink-muted` | Secondary text | `#5C574F` | Meta, captions | +| `surface` | Page background | `#FAF8F5` | Default canvas | +| `surface-elevated` | Cards | `#FFFFFF` | Cards, modals | +| `accent` | Primary CTA | `#C45C26` | Buttons, links (terracotta) | +| `accent-hover` | CTA hover | `#A34A1E` | Hover states | +| `gold` | Highlight | `#C9A227` | Featured badges, dividers (sparingly) | +| `festival` | Seasonal accent | `#8B2E5A` | Holi/Garba campaign strips (optional per edition) | +| `border` | Dividers | `#E8E4DD` | Hairlines | +| `success` | Valid ticket | `#2D6A4F` | Check-in success | +| `danger` | Errors | `#B42318` | Form errors | + +**Dark sections:** Hero and footer may use `ink` background with `surface` text for contrast blocks—use for one section per page max. + +--- + +## 4. Typography + +Use **two families** only (load via Google Fonts or self-host): + +| Role | Family | Tailwind class | Notes | +|------|--------|----------------|-------| +| Display | *Fraunces* or *Playfair Display* | `font-display` | H1–H2, editorial pull quotes | +| UI / body | *DM Sans* or *Inter* | `font-sans` | Body, buttons, labels | + +**Scale (mobile-first)** + +| Element | Size | Weight | Tracking | +|---------|------|--------|----------| +| H1 | `text-4xl` → `md:text-6xl` | 600 | tight | +| H2 | `text-3xl` → `md:text-4xl` | 600 | tight | +| H3 | `text-xl` | 600 | normal | +| Body | `text-base` | 400 | normal | +| Small / meta | `text-sm` | 400 | `text-ink-muted` | +| Button | `text-sm` uppercase | 600 | `tracking-wide` | + +**Line length:** Prose blocks `max-w-prose` (~65ch). + +--- + +## 5. Spacing & layout + +- **Grid:** 4px base; use Tailwind spacing scale consistently. +- **Page gutters:** `px-4` mobile, `px-6` tablet, `px-8` desktop; `max-w-7xl mx-auto` for content. +- **Section rhythm:** `py-16` md:`py-24` between major sections. +- **Cards:** `rounded-2xl`, subtle shadow `shadow-sm`, border `border-border`. + +--- + +## 6. Components (catalog) + +Build in `frontend/src/components/` with consistent naming: + +| Component | Description | +|-----------|-------------| +| `Button` | Primary, secondary (outline), ghost; min height 44px touch target | +| `Badge` | Event type, featured, sold out | +| `EventCard` | Cover, title, date, venue, CTA | +| `EventHero` | Full-width cover + gradient scrim + title | +| `MediaGallery` | Grid + lightbox (keyboard trap, focus return) | +| `TicketCTA` | Sticky on mobile for detail page | +| `Navbar` | Logo, Events, Subscribe; drawer on mobile | +| `Footer` | Links, social, newsletter | +| `Input`, `Label`, `FormError` | Accessible form primitives | +| `Skeleton` | Loading placeholders matching card layout | +| `AdminShell` | Sidebar + content (separate visual tone: denser, neutral) | + +**Icons:** Lucide React only; size `20` inline, `24` in buttons. + +--- + +## 7. Motion (GSAP + ScrollTrigger) + +**Setup** + +- Register `ScrollTrigger` once in `animations/gsap.js`. +- Export `prefersReducedMotion()` helper; if true, skip timelines and set final state. + +**Patterns** + +| Pattern | Use | Duration | +|---------|-----|----------| +| Hero fade + y | Home hero | 0.8–1.2s | +| Stagger cards | Event grid on scroll | 0.4s stagger | +| Parallax (subtle) | Hero image only | scrub 0.5 | + +**Rules** + +- No infinite loops on main content. +- `will-change` only during active animation. +- Do not animate `width`/`height`; use `transform` and `opacity`. + +--- + +## 8. Photography & imagery + +- **Aspect ratios:** Hero 16:9 or 3:2; cards 4:5 or 3:2. +- **Treatment:** Slight gradient overlay (`from-ink/60`) for text on images. +- **Alt text:** Required; describe the scene, not "event image". +- **Placeholders:** Neutral `surface` with shimmer skeleton, not gray boxes. + +--- + +## 9. Accessibility + +- Minimum touch target **44×44px**. +- Focus ring: `ring-2 ring-accent ring-offset-2 ring-offset-surface`. +- Color contrast: body text ≥ 4.5:1; large text ≥ 3:1. +- `prefers-reduced-motion`: disable ScrollTrigger scrubbing. +- Forms: associate labels, `aria-invalid`, `aria-describedby` for errors. +- Skip link: "Skip to main content" as first focusable element. + +--- + +## 10. Admin UI tone + +Admin is **utilitarian**, not marketing: + +- Neutral `surface` background, compact table density. +- Same `font-sans`; skip display font in admin except page titles. +- Primary actions still use `accent` for consistency. + +--- + +## 11. Content voice + +- **Headlines:** Specific ("Garba Under the Lights — KTPO Whitefield") not generic ("Amazing Night"). +- **CTAs:** "Get tickets", "View edition", "Join the list" — avoid "Click here". +- **Venues:** Always city + venue name when known. + +--- + +## 12. Tailwind configuration checklist + +- `content: ['./index.html', './src/**/*.{js,jsx}']` +- Extend `colors`, `fontFamily`, `borderRadius` as above +- No `@apply` abuse; prefer utility classes in JSX for clarity +- `plugins`: only `@tailwindcss/forms` if needed for input resets + +--- + +## 13. Do not use + +- Neon gradients, glassmorphism stacks, or concert-poster clutter. +- More than two font families. +- Auto-playing video with sound. +- Icon-only buttons without `aria-label`. + +--- + +## 14. References + +- [PRD.md](./PRD.md) — product scope +- [ARCHITECTURE.md](./ARCHITECTURE.md) — frontend structure diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..d351939 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,73 @@ +# Production performance — Desi Clans (public frontend) + +**Last audit:** 2026-04-04 +**Build:** `cd frontend && npm run build` + +## Summary + +| Area | Finding | Action taken | +|------|---------|--------------| +| **Bundle** | Admin + GSAP + Three on critical path | Route-level `React.lazy`, dynamic `loadGsap()`, isolated `hero-atmosphere` chunk | +| **Initial JS** | Single large entry | Split: `vendor-react`, `vendor-http`, `gsap`, `three`, per-route pages | +| **Images** | Full-width Unsplash URLs everywhere | `withImageWidth` / `srcSet` on hero, cards, featured, galleries | +| **Video** | None in app | N/A — no `