From 4fe46590d71cba84202660f3f8aa67eaa7cbcd62 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Mon, 23 Feb 2026 12:01:37 -0500 Subject: [PATCH 1/2] new structure --- apps/docs/mkdocs.yml | 45 ++++++++--- apps/docs/src/api/auth.md | 0 apps/docs/src/api/database.md | 0 apps/docs/src/api/db_testing.md | 30 -------- apps/docs/src/api/index.md | 28 ------- apps/docs/src/api/installation.md | 37 --------- apps/docs/src/api/migrations.md | 0 apps/docs/src/api/structure.md | 0 apps/docs/src/architecture.md | 38 ++++++++++ apps/docs/src/discord-bot/architecture.md | 0 apps/docs/src/discord-bot/commands.md | 1 - apps/docs/src/discord-bot/index.md | 35 --------- apps/docs/src/discord-bot/installation.md | 1 - apps/docs/src/docs/index.md | 15 ---- apps/docs/src/docs/installation.md | 36 --------- apps/docs/src/docs/writing-guide.md | 0 apps/docs/src/getting-started.md | 79 -------------------- apps/docs/src/index.md | 43 +++++++++-- apps/docs/src/infrastructure/cicd.md | 0 apps/docs/src/infrastructure/digitalocean.md | 0 apps/docs/src/infrastructure/docker.md | 0 apps/docs/src/infrastructure/index.md | 0 apps/docs/src/infrastructure/secrets.md | 0 apps/docs/src/operations/handoff.md | 0 apps/docs/src/operations/services.md | 0 apps/docs/src/operations/troubleshooting.md | 0 apps/docs/src/repo-structure.md | 0 apps/docs/src/web/api-integration.md | 0 apps/docs/src/web/architecture.md | 0 apps/docs/src/web/features.md | 46 ------------ apps/docs/src/web/index.md | 32 -------- apps/docs/src/web/installation.md | 43 ----------- apps/docs/src/web/routing-auth.md | 0 apps/docs/src/web/styling.md | 0 apps/docs/src/workflow.md | 0 35 files changed, 110 insertions(+), 399 deletions(-) create mode 100644 apps/docs/src/api/auth.md create mode 100644 apps/docs/src/api/database.md create mode 100644 apps/docs/src/api/migrations.md create mode 100644 apps/docs/src/api/structure.md create mode 100644 apps/docs/src/architecture.md create mode 100644 apps/docs/src/discord-bot/architecture.md create mode 100644 apps/docs/src/docs/writing-guide.md create mode 100644 apps/docs/src/infrastructure/cicd.md create mode 100644 apps/docs/src/infrastructure/digitalocean.md create mode 100644 apps/docs/src/infrastructure/docker.md create mode 100644 apps/docs/src/infrastructure/index.md create mode 100644 apps/docs/src/infrastructure/secrets.md create mode 100644 apps/docs/src/operations/handoff.md create mode 100644 apps/docs/src/operations/services.md create mode 100644 apps/docs/src/operations/troubleshooting.md create mode 100644 apps/docs/src/repo-structure.md create mode 100644 apps/docs/src/web/api-integration.md create mode 100644 apps/docs/src/web/architecture.md delete mode 100644 apps/docs/src/web/features.md create mode 100644 apps/docs/src/web/routing-auth.md create mode 100644 apps/docs/src/web/styling.md create mode 100644 apps/docs/src/workflow.md diff --git a/apps/docs/mkdocs.yml b/apps/docs/mkdocs.yml index 6958fbe4..78ef90e1 100644 --- a/apps/docs/mkdocs.yml +++ b/apps/docs/mkdocs.yml @@ -2,6 +2,7 @@ site_name: SwampHacks Documentation repo_url: https://github.com/swamphacks/core repo_name: SwampHacks Core docs_dir: src + theme: name: material icon: @@ -15,10 +16,12 @@ theme: - navigation.instant.progress - content.code.copy - content.action.edit + plugins: - search + copyright: > - Copyright © 2025 SwampHacks + Copyright © 2026 SwampHacks markdown_extensions: - pymdownx.highlight: @@ -30,33 +33,55 @@ markdown_extensions: - pymdownx.superfences - admonition - pymdownx.details - - pymdownx.superfences - attr_list - pymdownx.tabbed: alternate_style: true - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg + +extra: + generator: false + nav: - Home: - Introduction: index.md + - System Architecture: architecture.md + - Repository Structure: repo-structure.md - Getting Started: getting-started.md + - Development Workflow: workflow.md - Web: - Overview: web/index.md - - Installation: web/installation.md - - Features: web/features.md + - Installation & Setup: web/installation.md + - Architecture & State: web/architecture.md + - Routing & Auth: web/routing-auth.md + - API Integration: web/api-integration.md + - Styling & UI: web/styling.md - API: - Overview: api/index.md - - Installation: api/installation.md + - Installation & Setup: api/installation.md + - Project Structure: api/structure.md + - Authentication & Roles: api/auth.md + - Database Schema (Neon): api/database.md + - Migrations: api/migrations.md + - Database Testing: api/db_testing.md - OpenAPI: 'https://core.apidocumentation.com/guide/swamphacks-core-api' - - Database testing: 'api/db_testing.md' - Discord Bot: - Overview: discord-bot/index.md - Installation: discord-bot/installation.md - - Commands: discord-bot/commands.md + - Architecture: discord-bot/architecture.md + - Commands & Events: discord-bot/commands.md + - Infrastructure: + - Overview: infrastructure/index.md + - Docker Containers: infrastructure/docker.md + - Secrets Management (Infisical): infrastructure/secrets.md + - Deployment (DigitalOcean): infrastructure/digitalocean.md + - CI/CD Pipeline: infrastructure/cicd.md + - Operations: + - Third-Party Services: operations/services.md + - Troubleshooting: operations/troubleshooting.md + - Maintenance & Handoff: operations/handoff.md - Docs: - Overview: docs/index.md - Installation: docs/installation.md - -extra: - generator: false + - Writing Guide: docs/writing-guide.md diff --git a/apps/docs/src/api/auth.md b/apps/docs/src/api/auth.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/api/database.md b/apps/docs/src/api/database.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/api/db_testing.md b/apps/docs/src/api/db_testing.md index 6a0d5f19..e69de29b 100644 --- a/apps/docs/src/api/db_testing.md +++ b/apps/docs/src/api/db_testing.md @@ -1,30 +0,0 @@ -# Database testing - -## Manual - -1. Make sure the docker instance is currently running (i.e. you ran `$ docker compose up`) -!!! note - - You may have to run docker with sudo depending on your system configuration. - -1. In a seperate terminal (I suggest you try using tmux) list the current docker processes -``` bash -docker ps -``` - -1. Copy the process id for the docker container running postgres, and paste into the following command in order to spawn a shell with access to the container. -``` bash -sudo docker exec -it ef7XXXXXX07e sh -``` - -1. Connect to the postgres database using a database url. The url can be found inside `core/apps/api/.env.example`. -``` bash -psql postgres://postgres:postgres@postgres:5432/coredb -``` - -1. Check to see if database tables currently exist. You can by listing the currently created tables. -``` bash -\dt -``` -If there is nothing here, then go into `/core/apps/api` and run `make migrate`, which runs sql commands added to [migrations](https://en.wikipedia.org/wiki/Schema_migration) in `core/apps/api/internal/db/migrations`. -1. You can now test to see if rows, columns and tables are updated appropriately with psql commands. Use a [reference to psql](https://www.postgresql.org/docs/17/app-psql.html) if you need help finding commands. diff --git a/apps/docs/src/api/index.md b/apps/docs/src/api/index.md index 33ff70b0..e69de29b 100644 --- a/apps/docs/src/api/index.md +++ b/apps/docs/src/api/index.md @@ -1,28 +0,0 @@ -# API Overview - -The **SwampHacks API** is the core backend service that powers all technical systems, including the web dashboard and Discord bot. It acts as the central source of truth for users, applications, events, and teams. - -## Purpose - -The API provides structured, secure access to all hackathon data and functionality: - -- Serve and validate user sessions -- Enforce role-based access and permissions -- Handle CRUD operations across domains (users, events, projects, etc.) -- Connect frontend and automation tools through a consistent interface - -## Consumers - -The API is designed for use by: - -- **Web Dashboard**: UI for organizers, hackers, mentors, and judges -- **Discord Bot**: Handles real-time updates, commands, and integrations -- **Internal Tools**: Scripts and workflows (e.g. onboarding, analytics) - -## Integration - -All clients communicate with the API via HTTP using secure, authenticated requests. The system is designed for modular growth and can easily support future bots, portals, or tools. - ---- - -This page offers a high-level overview. For deeper implementation or contributing info, see other sections of the docs. diff --git a/apps/docs/src/api/installation.md b/apps/docs/src/api/installation.md index d6840531..e69de29b 100644 --- a/apps/docs/src/api/installation.md +++ b/apps/docs/src/api/installation.md @@ -1,37 +0,0 @@ -# Getting Started - -### Setup with Docker Compose (main setup) -1. Navigate to `core/apps/api` - -1. **Set up environment variables**: -``` bash -cp .env.dev.example .env.dev -``` - -1. Open `.env.dev` - - 1. For `AUTH_DISCORD_CLIENT_ID` and `AUTH_DISCORD_CLIENT_SECRET`, go to the Discord developer portal and create an account. Create a new application and go to the OAuth2 tab in the left sidebar. Copy the Client ID and the Client Secret into their respective environment variables. - - 1. While in the OAuth2 menu, copy the `AUTH_DISCORD_REDIRECT_URI` parameter from the example configuration and paste into the box under the *Redirects* header. This is the URL which discord will redirect the user to after Discord authentication has completed. - - 1. Fill out any other required keys and tokens, if empty. - -1. Continue with the [main setup instructions](../getting-started.md) - -### Setup without Docker Compose - -1. Make sure you have [Go](https://go.dev/) installed on your system. -1. Initialize the Go project -``` bash -go mod tidy -``` -``` bash -go install github.com/air-verse/air@latest -go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest -go install github.com/pressly/goose/v3/cmd/goose@latest -``` - -1. Run the program with -```bash -air -``` diff --git a/apps/docs/src/api/migrations.md b/apps/docs/src/api/migrations.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/api/structure.md b/apps/docs/src/api/structure.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/architecture.md b/apps/docs/src/architecture.md new file mode 100644 index 00000000..44cf3839 --- /dev/null +++ b/apps/docs/src/architecture.md @@ -0,0 +1,38 @@ +# System Architecture + +The SwampHacks Core platform is designed around a central API that acts as the "brain" of the operation, coordinating data between our web client, background workers, external bots, and databases. + +## 1. The Core API (The Brain) +* **Tech:** Golang +* **Role:** Acts as the central source of truth. It exposes RESTful endpoints, enforces authentication and authorization, and directly manages all database transactions. All other services (frontend, bot, workers) interact with or are driven by this API. + +## 2. Background Workers +To keep the Core API performant, heavy or asynchronous tasks are offloaded to dedicated background workers written in Go. + +* **Queue Management:** Redis is used to manage the task queues. (Note: Redis is currently strictly for worker queues but is provisioned to handle API caching in the future). + +* **Email Worker:** Listens for events and handles reliable, asynchronous email dispatch to users. + +* **BAT Worker (Balanced Admissions Thresher):** Executes the complex logic for hacker admissions. While currently implemented as a worker, its logic is decoupled enough to be extracted into a standalone service if scaling requires it. + +## 3. Web Frontend +* **Tech:** React, TanStack Router + +* **Role:** A lightweight single-page application (SPA) providing the user interface for hackers, judges, and organizers. It handles client-side routing via TanStack Router and relies entirely on the Core API for state and data persistence. + +## 4. Discord Bot +* **Tech:** Python (discord.py) + +* **Role:** Manages the SwampHacks Discord server. It interfaces directly with the Core API to perform tasks such as linking hacker accounts to Discord profiles, managing role assignments based on platform status, and triggering announcements. + +## 5. Data Layer +* **Primary Database:** Neon (Serverless PostgreSQL). Stores all persistent application data, including users, applications, teams, and scoring metrics. + +* **Message Broker / Cache:** Redis. + +## 6. Infrastructure & Deployment +* **Containerization:** Docker is used universally. Both local development and production environments run identical Docker containers to eliminate "works on my machine" issues. + +* **CI/CD:** GitHub Actions manages our pipelines, automatically running linters and triggering deployments upon merged pull requests. + +* **Secrets Management:** Infisical. Hardcoded secrets do not exist in the repository. During deployment, the production server pulls the necessary environment variables directly from Infisical before spinning up the containers. diff --git a/apps/docs/src/discord-bot/architecture.md b/apps/docs/src/discord-bot/architecture.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/discord-bot/commands.md b/apps/docs/src/discord-bot/commands.md index 4b68ccc6..e69de29b 100644 --- a/apps/docs/src/discord-bot/commands.md +++ b/apps/docs/src/discord-bot/commands.md @@ -1 +0,0 @@ -SwampHacks Discord Bot commands will go here. diff --git a/apps/docs/src/discord-bot/index.md b/apps/docs/src/discord-bot/index.md index e7e33953..e69de29b 100644 --- a/apps/docs/src/discord-bot/index.md +++ b/apps/docs/src/discord-bot/index.md @@ -1,35 +0,0 @@ -# Discord Bot Overview - -The **SwampHacks Discord Bot** is our automation layer inside the event’s main communication hub. It connects participants, mentors, and organizers with live event features — directly within Discord. - -## Purpose - -The bot enhances engagement and coordination by: - -- Automating onboarding and role assignment -- Sharing real-time event updates and announcements -- Handling FAQs, schedules, and project links -- Bridging the gap between Discord and our internal systems - -## Users - -The bot is built for: - -- **Hackers**: Instant answers, project support, reminders -- **Mentors**: Role signup and availability status -- **Organizers**: Command tools, announcement scheduling, moderation support - -## Key Features - -- Slash commands for info, schedules, and submissions -- Auto-role assignment based on application data -- Realtime announcements synced from the dashboard -- Event reminders and support workflows - -## Integration - -The bot communicates with the SwampHacks API for user data, permissions, and live content. It is fully event-aware and supports syncing updates in real-time from other systems. - ---- - -This overview introduces the bot’s role. For setup or contribution info, refer to the the latter sections of the docs. diff --git a/apps/docs/src/discord-bot/installation.md b/apps/docs/src/discord-bot/installation.md index 8b3a7945..e69de29b 100644 --- a/apps/docs/src/discord-bot/installation.md +++ b/apps/docs/src/discord-bot/installation.md @@ -1 +0,0 @@ -# Getting Started \ No newline at end of file diff --git a/apps/docs/src/docs/index.md b/apps/docs/src/docs/index.md index 7280abc8..e69de29b 100644 --- a/apps/docs/src/docs/index.md +++ b/apps/docs/src/docs/index.md @@ -1,15 +0,0 @@ -# Docs Overview - -Documentation system to make sure everyone knows how everything works. Vital for the longevity of any long term software engineering project. - -## Rationale - -We decided to make our documentation using a MkDocs project living in the same GitHub repository as everything else. Why this over something like a GitHub wiki? The answer is simple: - -- Developers can add documentation changes in the same Git commits as their work. This ensures that when a new feature is added, it will be apparent whether or not related documentation was also made when a pull request is reviewed. - -- GitHub Actions can hook up pushed changes to update a website, meaning no one person has to manage changes to the documentation in a seperate repository. - -- Writing in markdown files can be moved to a different documentation site generator if needed, and can be easily edited by non-engineers. - - diff --git a/apps/docs/src/docs/installation.md b/apps/docs/src/docs/installation.md index 8504d8aa..e69de29b 100644 --- a/apps/docs/src/docs/installation.md +++ b/apps/docs/src/docs/installation.md @@ -1,36 +0,0 @@ -# Gettings Started - -## Normal setup - -1. Install [mkdocs](https://www.mkdocs.org/user-guide/installation/) -1. Install the python packages listed in core/apps/docs/requirements.txt -1. Run: - -```bash -mkdocs serve -``` - -!!! warning - If the site does not generate because there is still missing dependancies, you can find them using [mkdocs-get-deps](https://github.com/mkdocs/get-deps) - -## For linux distributions which do not package python packages through pip - -This includes Arch Linux. - -1. Run -```bash -python -m venv env -source env/bin/activate -``` -1. Install packages -```bash -pip install -r requirements.txt -``` -1. Run with -```bash -mkdocs serve -``` - ---- - -Now just make normal changes to the documentation and the site will update automatically. Make commits and push your changes when you're done! diff --git a/apps/docs/src/docs/writing-guide.md b/apps/docs/src/docs/writing-guide.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/getting-started.md b/apps/docs/src/getting-started.md index dc0e37a8..e69de29b 100644 --- a/apps/docs/src/getting-started.md +++ b/apps/docs/src/getting-started.md @@ -1,79 +0,0 @@ -# Getting Started - -Welcome to the monorepo for our hackathon event management system. This repository contains all major services powering our platform, including: - -- **Web** – The frontend dashboard for organizers and attendees. -- **API** – The core backend service responsible for business logic, data management, and integrations. -- **Discord Bot** – A companion bot for community engagement and operations. (Currently independent, may depend on the API in the future.) - -Each project has its own dedicated documentation tab for detailed setup and usage. This page covers the global setup needed to get the monorepo running locally. - ---- - -## Prerequisites - -Ensure the following are installed: - -- [Docker Engine](https://docs.docker.com/engine/install/) -- [Docker Compose](https://docs.docker.com/compose/install/) (if not included with Docker) -- [Git](https://git-scm.com) -- `make` (optional, but helpful for managing workflows) - ---- - -## Initial Setup - -1. **Clone the repository**: -``` bash title="Terminal/Shell" -git clone https://github.com/swamphacks/core - -cd core -``` -2. **Set Up the API** -The API powers the platform's business logic, database operations, and integrations. -Please follow the [API's Installation Guide](api/installation.md) to complete the setup. -Once you're done, return here to continue with the other services. - -3. **Set Up the Web Dashboard** -After the API is running, you can set up the frontend dashboard for organizers and attendees. -Follow the [Web Installation Guide](web/installation.md) to install dependencies, configure environment variables, and run the app locally. - - -4. **Set Up the Discord Bot (OPTIONAL)** -The Discord Bot helps manage community engagement and provides real-time updates during the event. -Refer to the [Discord Bot Installation Guide](discord-bot/installation.md) for instructions on setup, permissions, and development workflow. - -Now that you are done with setting up each project, continue down below to developing using docker. - ---- - -## Running Development with Docker - -!!! warning "Ensure Running In Root Directory" - The following commands to begin development with docker **MUST** be run in root. Your working directory should be `core/`. - -Now that you have all the main projects set up, you can use docker and docker compose to quickly start a development environment. - -### **Docker Compose** -We can use `docker compose` to quickly start up both our API and Web projects as well as local databases and caches. -In the root of the monorepo, run the following in your terminal: -``` bash title="Terminal/Shell" -docker compose up -``` -### **Docker Compose with Rebuilding** -Sometimes we need to rebuild the docker environment and container in order to apply changes. This is more of a rare instance but in case you do need to ensure everything is rebuilt, run docker with the `--build` flag. -``` bash title="Terminal/Shell" -docker compose up --build -``` -### **Docker Compose for API Development** -For all our backend developers out there, most of the time you don't need to spin up every application in order to use and test the backend. In cases where you only want to run the API without the web, specify `api` in the command. -``` bash title="Terminal/Shell" -docker compose up api -``` - -!!! info "Database and Cache" - Although it won't start the web service anymore, the database and redis cache **WILL** be started every single time the API service runs as it depends on the database and redis cache to function. - ---- - -> ✅ Be sure to check that the API is running properly before launching the Web or Bot services, as they may rely on API endpoints. diff --git a/apps/docs/src/index.md b/apps/docs/src/index.md index e5155209..7eb34e7e 100644 --- a/apps/docs/src/index.md +++ b/apps/docs/src/index.md @@ -1,11 +1,42 @@ +# Welcome to SwampHacks Core -# Core +Welcome to the official developer documentation for the **SwampHacks Core** platform. -Welcome to **Core**, the central hub for all things tech at **SwampHacks**. This repository is your go-to source for everything related to our technology, tools, and development efforts. Whether you're a new contributor or a seasoned member, you'll find what you need here. +This platform serves as the central nervous system for the SwampHacks event. It handles hacker registration, team formation, application review, project submissions, and day-of-event operations. -**Core** is the foundation of SwampHacks's tech ecosystem. It includes: +Whether you are a new organizer joining the technical team or looking to understand how the platform operates under the hood, this documentation will guide you through our architecture, local setup, and deployment workflows. -- **Documentation**: Guides, tutorials, and resources. -- **Code**: Backend and frontend code for our event management systems, bots, and other projects. -- **Resources**: Templates, best practices, and tools to help you get started. +--- +## The Tech Stack + +Our platform is built with a modern, scalable stack designed for rapid development and high availability during the event: + +* **Frontend:** React + Vite +* **Backend:** Go +* **Database:** Neon DB (Serverless Postgres) +* **Secrets Management:** Infisical +* **Infrastructure:** Docker & DigitalOcean +* **Community:** Discord Bot (Custom) + +--- + +## How to Use These Docs + +Use the top navigation tabs to explore different domains of the platform: + +* **Home:** Start here! Understand the high-level architecture, how the repository is structured, and how to get your local development environment running. +* **Web:** Everything related to the frontend user interface, state management, and API integration. +* **API:** Deep dive into the backend business logic, endpoints, database schema, and authentication. +* **Discord Bot:** Documentation for our custom Discord bot, including commands and event handling. +* **Infrastructure:** The DevOps playbook. Learn how we use Docker, manage secrets with Infisical, and deploy to DigitalOcean. +* **Operations:** Crucial information for event day, troubleshooting guides, and credentials for third-party services. + +!!! tip "Just getting started?" + If you are setting up the project for the first time, head straight over to the [Getting Started](getting-started.md) guide to spin up your local environment. + +--- + +## Contributing + +As an organizer, you are encouraged to keep these docs updated. If you add a new API endpoint, change the database schema, or update a deployment script, please update the corresponding page in these docs so the knowledge is passed down to the next team! diff --git a/apps/docs/src/infrastructure/cicd.md b/apps/docs/src/infrastructure/cicd.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/infrastructure/digitalocean.md b/apps/docs/src/infrastructure/digitalocean.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/infrastructure/docker.md b/apps/docs/src/infrastructure/docker.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/infrastructure/index.md b/apps/docs/src/infrastructure/index.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/infrastructure/secrets.md b/apps/docs/src/infrastructure/secrets.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/operations/handoff.md b/apps/docs/src/operations/handoff.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/operations/services.md b/apps/docs/src/operations/services.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/operations/troubleshooting.md b/apps/docs/src/operations/troubleshooting.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/repo-structure.md b/apps/docs/src/repo-structure.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/web/api-integration.md b/apps/docs/src/web/api-integration.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/web/architecture.md b/apps/docs/src/web/architecture.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/web/features.md b/apps/docs/src/web/features.md deleted file mode 100644 index 00c747b5..00000000 --- a/apps/docs/src/web/features.md +++ /dev/null @@ -1,46 +0,0 @@ - -## Authentication -SwampHacks' authentication system is built using our own authentication library, with an API inspired by NextAuth.js. -Currently, it supports [Discord Oauth2](https://discord.com/developers/docs/topics/oauth2) for log in and registration. - -### Implementation -Our auth library uses the Authorization Code Grant, which allows our backend server to retrieve an access code and exchange it for the user's access token. - -The auth library can be initialized like so: - -```typescript -const authClient = Auth({ - providers: [Discord], - redirect_uri: , -}); -``` - -The library is flexible enough to handle more providers other than Discord. -A provider has following type: - -```typescript -type Provider = { - id: string; - authorization: { - url: string; - scopes: string; - clientId: string; - }; -} -``` - -To create a new provider, the `createProvider` function in `providers.ts` must be called so that types are inferred correctly. - -For example: - -```typescript -const Google = createProvider({ - id: "google", - authorization: { - url: , - scopes: , - clientId: , - }, -}); - -``` \ No newline at end of file diff --git a/apps/docs/src/web/index.md b/apps/docs/src/web/index.md index 5b739217..e69de29b 100644 --- a/apps/docs/src/web/index.md +++ b/apps/docs/src/web/index.md @@ -1,32 +0,0 @@ -# Web Overview - -The **SwampHacks Web Dashboard** is the central interface for managing the hackathon experience. It brings together organizers, hackers, mentors, and judges in a unified, role-aware platform. - -## Purpose - -The dashboard is built to support the full lifecycle of a hackathon: - -- Organize events and logistics -- Manage participants, mentors, and judges -- Enable registration, scheduling, and submissions -- Provide real-time updates during the event - -## Users - -The system supports multiple roles: - -- **Organizers**: Admin views, user management, analytics -- **Hackers**: Registration, team creation, event notifications -- **Judges**: Project browsing and scoring (coming soon) - -## Key Features - -- User registration and application review. -- Event/Workshop scheduling -- Check in and hardware integrations -- Discord and other media channel integrations -- And much more... - -## Integration - -The dashboard communicates with our internal API and supports real-time updates. Future versions will integrate directly with Discord and sponsor tooling. diff --git a/apps/docs/src/web/installation.md b/apps/docs/src/web/installation.md index cf738aaa..e69de29b 100644 --- a/apps/docs/src/web/installation.md +++ b/apps/docs/src/web/installation.md @@ -1,43 +0,0 @@ -# Getting Started - -### Setup with Docker Compose (main setup) -1. Navigate to `core/apps/web` - -2. **Set up environment variables**: -``` bash -cp .env.example .env -``` - -Fill in the required keys and tokens in your new `.env` file. For `VITE_DISCORD_OAUTH_CLIENT_ID`, retrive the token via the instructions given in [the API installation page](../api/installation.md). - -`VITE_` must be prefixed to all environment variables in order for them to be accessible. - -3. Continue with the [main setup instructions](../getting-started.md) - -### Setup without Docker Compose - -1. Make sure [pnpm](https://pnpm.io/) is installed on your system. - -2. Navigate to `core/apps/web` - -3. Install dependencies - -```bash -pnpm install -``` - -4. Configure environment variables: - -```bash -cp .env.example .env -``` - -Fill in the required keys and tokens in your new `.env` file. - -`VITE_` must be prefixed to all environment variables in order for them to be accessible. - -5. Finally, launch the app - -```bash -pnpm run dev -``` diff --git a/apps/docs/src/web/routing-auth.md b/apps/docs/src/web/routing-auth.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/web/styling.md b/apps/docs/src/web/styling.md new file mode 100644 index 00000000..e69de29b diff --git a/apps/docs/src/workflow.md b/apps/docs/src/workflow.md new file mode 100644 index 00000000..e69de29b From 2bdd2b9f49812ad228cf97d07b0f19493fc53671 Mon Sep 17 00:00:00 2001 From: AlexanderWangY Date: Wed, 11 Mar 2026 15:21:06 -0500 Subject: [PATCH 2/2] docs: update docs for api, web, and infra --- apps/docs/mkdocs.yml | 2 +- apps/docs/src/api/auth.md | 108 +++++++ apps/docs/src/api/database.md | 230 ++++++++++++++ apps/docs/src/api/db_testing.md | 85 +++++ apps/docs/src/api/index.md | 61 ++++ apps/docs/src/api/installation.md | 89 ++++++ apps/docs/src/api/migrations.md | 98 ++++++ apps/docs/src/api/openapi.md | 151 +++++++++ apps/docs/src/api/structure.md | 89 ++++++ apps/docs/src/discord-bot/installation.md | 39 +++ apps/docs/src/getting-started.md | 99 ++++++ apps/docs/src/infrastructure/cicd.md | 142 +++++++++ apps/docs/src/infrastructure/digitalocean.md | 186 +++++++++++ apps/docs/src/infrastructure/docker.md | 168 ++++++++++ apps/docs/src/infrastructure/index.md | 83 +++++ apps/docs/src/infrastructure/secrets.md | 201 ++++++++++++ apps/docs/src/web/api-integration.md | 271 ++++++++++++++++ apps/docs/src/web/architecture.md | 237 ++++++++++++++ apps/docs/src/web/index.md | 62 ++++ apps/docs/src/web/installation.md | 54 ++++ apps/docs/src/web/routing-auth.md | 281 +++++++++++++++++ apps/docs/src/web/styling.md | 312 +++++++++++++++++++ 22 files changed, 3047 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/api/openapi.md diff --git a/apps/docs/mkdocs.yml b/apps/docs/mkdocs.yml index 78ef90e1..2c3f904b 100644 --- a/apps/docs/mkdocs.yml +++ b/apps/docs/mkdocs.yml @@ -65,7 +65,7 @@ nav: - Database Schema (Neon): api/database.md - Migrations: api/migrations.md - Database Testing: api/db_testing.md - - OpenAPI: 'https://core.apidocumentation.com/guide/swamphacks-core-api' + - OpenAPI: api/openapi.md - Discord Bot: - Overview: discord-bot/index.md - Installation: discord-bot/installation.md diff --git a/apps/docs/src/api/auth.md b/apps/docs/src/api/auth.md index e69de29b..0d1ba316 100644 --- a/apps/docs/src/api/auth.md +++ b/apps/docs/src/api/auth.md @@ -0,0 +1,108 @@ +# Authentication & Roles + +## Overview + +The API uses **Discord OAuth2** for authentication. On success, the server issues a session cookie. All protected routes validate this cookie on every request. + +A separate key-based scheme exists for the mobile check-in app. + +--- + +## OAuth2 Flow + +``` +Client API Discord + │ │ │ + │── GET /auth/callback?code ──▶│ │ + │ │── exchange code ────────────▶│ + │ │◀─ access token ─────────────│ + │ │── GET /users/@me ───────────▶│ + │ │◀─ Discord user info ─────────│ + │ │ │ + │ │ (upsert user + session) │ + │ │ │ + │◀─ Set-Cookie: sh_session_id ─│ │ + │◀─ 302 → CLIENT_URL ──────────│ │ +``` + +1. The frontend initiates the OAuth2 flow by redirecting the user to Discord with a `state` nonce stored in the `sh_auth_nonce` cookie. +2. Discord redirects back to `/auth/callback` with a `code` and `state`. +3. The API validates the nonce, exchanges the code for a Discord access token, and fetches the user's Discord profile. +4. If the user is new, an `auth.users` record and `auth.accounts` record are created in a transaction. Otherwise, a new session is created for the existing user. +5. The session ID is set as the `sh_session_id` cookie and the user is redirected to the frontend. + +--- + +## Session Validation + +Every request to a protected route goes through `RequireAuth` middleware: + +1. Reads the `sh_session_id` cookie. +2. Looks up the session in `auth.sessions` (must not be expired). +3. Fetches the associated user record. +4. Attaches a `UserContext` to the request context. + +**Rolling expiration:** If the session has not been used in the past 24 hours, its expiration is extended by 30 days and the cookie is refreshed. + +### UserContext fields + +| Field | Type | Description | +|---|---|---| +| `UserID` | UUID | Unique user identifier | +| `Email` | `*string` | Primary email from Discord | +| `PreferredEmail` | `*string` | User-set preferred email | +| `Name` | string | Display name | +| `Onboarded` | bool | Whether onboarding is complete | +| `Image` | `*string` | Profile image URL | +| `Role` | `AuthUserRole` | Platform role (`user` or `superuser`) | +| `EmailConsent` | bool | Whether the user opted into emails | + +--- + +## Platform Roles + +Two platform-level roles are defined in `auth_user_role`: + +| Role | Description | +|---|---| +| `user` | Default role for all registered users | +| `superuser` | Full access; bypasses all role checks | + +Platform roles are enforced by `RequirePlatformRole(roles)` middleware. Superusers bypass this check unconditionally. + +--- + +## Event Roles + +Users can have a role within a specific event, stored in `event_roles`: + +| Role | Description | +|---|---| +| `admin` | Full event management (create/delete/assign roles, release decisions) | +| `staff` | Event operations (check-in, review applications, manage redeemables) | +| `attendee` | Accepted attendee | +| `applicant` | Has submitted an application | + +Event roles are enforced by `RequireEventRole(roles)` middleware, which fetches the user's role for the event from the URL path. Superusers bypass event role checks. + +--- + +## Mobile Authentication + +The mobile check-in app uses a static key instead of session cookies: + +``` +Authorization: Key +``` + +Routes under `/mobile` require this header. The key is configured via the `MOBILE_AUTH_KEY` environment variable (not in `.env.dev.example` — request it from the team). + +--- + +## Endpoints + +| Method | Path | Auth | Description | +|---|---|---|---| +| `GET` | `/auth/callback` | None | OAuth2 callback | +| `GET` | `/auth/me` | Session | Get current user | +| `POST` | `/auth/logout` | Session | Invalidate session | diff --git a/apps/docs/src/api/database.md b/apps/docs/src/api/database.md index e69de29b..28275aa2 100644 --- a/apps/docs/src/api/database.md +++ b/apps/docs/src/api/database.md @@ -0,0 +1,230 @@ +# Database Schema + +The API uses **PostgreSQL 17**. Auth-related tables live in the `auth` schema; everything else is in the default `public` schema. + +All tables include `created_at` and `updated_at` timestamps. `updated_at` is maintained automatically by a `update_modified_column()` trigger. + +--- + +## Auth Schema + +### `auth.users` + +Core user accounts. One record per registered user. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | Auto-generated | +| `name` | TEXT | Display name from Discord | +| `email` | TEXT UNIQUE | Primary email from Discord | +| `preferred_email` | TEXT | User-set preferred email | +| `email_verified` | BOOLEAN | Default `false` | +| `email_consent` | BOOLEAN | Marketing email opt-in | +| `onboarded` | BOOLEAN | Whether onboarding is complete | +| `image` | TEXT | Profile image URL | +| `role` | `auth_user_role` | `user` or `superuser` | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +### `auth.accounts` + +OAuth provider associations. A user can have multiple providers (currently only Discord). + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `user_id` | UUID FK → `auth.users` | | +| `provider_id` | TEXT | e.g., `discord` | +| `account_id` | TEXT | Provider's user ID | +| `access_token` | TEXT | | +| `refresh_token` | TEXT | | +| `scope` | TEXT | | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +Unique constraint on `(provider_id, account_id)`. + +### `auth.sessions` + +Active user sessions. Sessions expire and use rolling expiration. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | Stored in `sh_session_id` cookie | +| `user_id` | UUID FK → `auth.users` | | +| `expires_at` | TIMESTAMPTZ | Extended on use after 24h | +| `ip_address` | TEXT | | +| `user_agent` | TEXT | | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | Used as `last_used_at` | + +--- + +## Public Schema + +### `events` + +Hackathon events. Drives the application and attendee lifecycle. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `name` | TEXT | | +| `description` | TEXT | | +| `location` | TEXT | | +| `location_url` | TEXT | | +| `max_attendees` | INT | Optional cap | +| `application_open` | TIMESTAMPTZ | Applications open | +| `application_close` | TIMESTAMPTZ | Applications close | +| `rsvp_deadline` | TIMESTAMPTZ | | +| `decision_release` | TIMESTAMPTZ | When decisions are released to applicants | +| `start_time` | TIMESTAMPTZ | | +| `end_time` | TIMESTAMPTZ | | +| `website_url` | TEXT | | +| `banner_url` | TEXT | R2 object key for the banner image | +| `is_published` | BOOLEAN | `false` = draft (only staff+ can see) | +| `application_review_started` | BOOLEAN | Locks in reviewer assignments | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +### `event_roles` + +Maps users to their role within a specific event. + +| Column | Type | Notes | +|---|---|---| +| `user_id` | UUID FK → `auth.users` | | +| `event_id` | UUID FK → `events` | | +| `role` | `event_role_type` | `admin`, `staff`, `attendee`, `applicant` | +| `assigned_at` | TIMESTAMPTZ | | + +Primary key: `(user_id, event_id)`. + +### `applications` + +One application per user per event. Stores the form data as JSONB. + +| Column | Type | Notes | +|---|---|---| +| `user_id` | UUID FK → `auth.users` | | +| `event_id` | UUID FK → `events` | | +| `status` | `application_status` | See statuses below | +| `application` | JSONB | Form field data | +| `experience_rating` | INTEGER | Reviewer score (1–5) | +| `passion_rating` | INTEGER | Reviewer score (1–5) | +| `assigned_reviewer_id` | UUID FK → `auth.users` | Nullable | +| `submitted_by` | UUID | User ID at time of submission | +| `waitlisted_at` | TIMESTAMPTZ | When the applicant was waitlisted | +| `saved_at` | TIMESTAMPTZ | Last draft save | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +Primary key: `(user_id, event_id)`. + +**`application_status` enum:** + +| Value | Description | +|---|---| +| `started` | Draft — created but not submitted | +| `submitted` | Submitted, awaiting review | +| `under_review` | Assigned to a reviewer | +| `accepted` | Accepted by BAT run | +| `rejected` | Rejected by BAT run | +| `waitlisted` | On waitlist | +| `withdrawn` | Withdrawn by applicant | + +### `bat_runs` + +Records of BAT (Balanced Admissions Thresher) execution results. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `event_id` | UUID FK → `events` | | +| `accepted_applicants` | UUID[] | Array of user IDs | +| `rejected_applicants` | UUID[] | Array of user IDs | +| `status` | `bat_run_status` | `running`, `completed`, `failed` | +| `created_at` | TIMESTAMPTZ | | +| `completed_at` | TIMESTAMPTZ | Nullable | + +### `teams` + +Teams within an event. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `name` | TEXT | | +| `owner_id` | UUID FK → `auth.users` | Nullable (SET NULL on delete) | +| `event_id` | UUID FK → `events` | | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +### `team_members` + +Many-to-many membership join table. + +| Column | Type | Notes | +|---|---|---| +| `user_id` | UUID FK → `auth.users` | | +| `team_id` | UUID FK → `teams` | | +| `joined_at` | TIMESTAMPTZ | | + +Primary key: `(user_id, team_id)`. + +### `team_join_requests` + +Requests from users to join a team. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `team_id` | UUID FK → `teams` | | +| `user_id` | UUID FK → `auth.users` | | +| `request_message` | TEXT | Optional message | +| `status` | `join_request_status` | `PENDING`, `APPROVED`, `REJECTED` | +| `processed_by_user_id` | UUID FK → `auth.users` | Nullable | +| `processed_at` | TIMESTAMPTZ | Nullable | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +Unique partial index: one `PENDING` request per `(team_id, user_id)`. + +### `event_interest_submissions` + +Mailing list for event interest (pre-registration). + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `event_id` | UUID FK → `events` | | +| `email` | TEXT | | +| `created_at` | TIMESTAMPTZ | | + +### `redeemables` + +Prize or reward items associated with an event. + +| Column | Type | Notes | +|---|---|---| +| `id` | UUID PK | | +| `event_id` | UUID FK → `events` | | +| `name` | VARCHAR(255) | | +| `amount` | INT | Total available (≥ 0) | +| `max_user_amount` | INT | Per-user limit (≥ 1) | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +### `user_redemptions` + +Tracks how many times a user has redeemed a specific redeemable. + +| Column | Type | Notes | +|---|---|---| +| `user_id` | UUID FK → `auth.users` | | +| `redeemable_id` | UUID FK → `redeemables` | | +| `amount` | INT | Times redeemed (≥ 0) | +| `created_at` | TIMESTAMPTZ | | +| `updated_at` | TIMESTAMPTZ | | + +Primary key: `(user_id, redeemable_id)`. diff --git a/apps/docs/src/api/db_testing.md b/apps/docs/src/api/db_testing.md index e69de29b..f8fead84 100644 --- a/apps/docs/src/api/db_testing.md +++ b/apps/docs/src/api/db_testing.md @@ -0,0 +1,85 @@ +# Database Testing + +The API does not currently have automated unit or integration tests. This page covers how to work with the database during local development — inspecting data, running ad-hoc queries, and verifying migrations. + +## Connecting to the local database + +When the stack is running via Docker, PostgreSQL is exposed on `localhost:5432`. + +**Connection string:** +``` +postgres://postgres:postgres@localhost:5432/coredb +``` + +Connect with `psql`: + +```bash +psql postgres://postgres:postgres@localhost:5432/coredb +``` + +Or use a GUI client (TablePlus, DBeaver, DataGrip) with the same credentials. + +## Useful queries + +**Check applied migrations:** +```sql +SELECT version_id, is_applied, tstamp +FROM goose_db_version +ORDER BY id DESC; +``` + +**Inspect user sessions:** +```sql +SELECT id, user_id, expires_at, updated_at AS last_used_at +FROM auth.sessions +ORDER BY updated_at DESC +LIMIT 20; +``` + +**View applications by status:** +```sql +SELECT status, COUNT(*) +FROM applications +GROUP BY status +ORDER BY count DESC; +``` + +**Check BAT run results:** +```sql +SELECT id, status, array_length(accepted_applicants, 1) AS accepted, + array_length(rejected_applicants, 1) AS rejected, created_at +FROM bat_runs +ORDER BY created_at DESC; +``` + +## Resetting the database + +To wipe and recreate the local database (useful after a bad migration or schema experiment): + +```bash +# Stop and remove the postgres container + its volume +docker compose down -v + +# Restart — migrations run automatically on startup +make local +``` + +!!! warning + `docker compose down -v` deletes all persisted data including the `postgres_data` volume. Only do this locally. + + + +## Inspecting the task queue + +[Asynqmon](http://localhost:6767) provides a web UI for inspecting queued, active, and failed tasks when running `make local` or `make backend`. Use it to verify that email and BAT tasks are being enqueued and processed correctly. + +## Working with sqlc + +All SQL queries are in `internal/db/queries/`. After modifying a query or migration, regenerate the Go bindings: + +```bash +cd apps/api +make generate +``` + +The generated code in `internal/db/sqlc/` reflects the current schema and query set. If `make generate` fails, the SQL query is invalid against the current schema — fix the query or migration before proceeding. diff --git a/apps/docs/src/api/index.md b/apps/docs/src/api/index.md index e69de29b..bad4ef3f 100644 --- a/apps/docs/src/api/index.md +++ b/apps/docs/src/api/index.md @@ -0,0 +1,61 @@ +# API Overview + +The SwampHacks API is the central backend for the platform. It handles authentication, event management, applications, teams, email delivery, and check-in operations. + +## Stack + +| Component | Technology | +|---|---| +| Language | Go 1.24 | +| Router | [chi](https://github.com/go-chi/chi) | +| Database | PostgreSQL 17 (via [pgx](https://github.com/jackc/pgx)) | +| Query layer | [sqlc](https://sqlc.dev) (generated, type-safe) | +| Task queue | [Asynq](https://github.com/hibiken/asynq) + Redis | +| Object storage | Cloudflare R2 (S3-compatible) | +| Email | AWS SES | +| Auth | Discord OAuth2 + session cookies | +| API docs | [Scalar](https://scalar.com) (served at `/docs`) | + +## Architecture + +The API follows a strict layered architecture: + +``` +HTTP Request + └── Middleware (auth, roles, logging) + └── Handler (parse, validate, respond) + └── Service (business logic) + └── Repository (data access) + └── Database (PostgreSQL via sqlc) +``` + +Each layer has a single responsibility. Handlers never touch the database directly; services never parse HTTP requests. + +## Background Workers + +Two separate processes handle async work and run alongside the API: + +- **Email Worker** — processes the email task queue (confirmation emails, welcome emails, decision emails) +- **BAT Worker** — runs the Balanced Admissions Thresher, which calculates accept/reject/waitlist decisions from reviewer scores + +Both workers share the same codebase and configuration as the API but are started as separate binaries. + +## Key Domains + +| Domain | Description | +|---|---| +| Auth | Discord OAuth2 login, session management | +| Users | Profiles, onboarding, email consent | +| Events | Hackathon event lifecycle, banners, scopes | +| Applications | Submission, review assignment, BAT decisions, waitlist | +| Teams | Creation, join requests, membership | +| Redeemables | Prize tracking and redemption | +| Email | Async delivery via SES + task queue | +| Discord | Role lookup and attendee queries for the bot | +| Mobile | RFID-based check-in for the mobile check-in app | + +## API Documentation + +Interactive API documentation is available at `/docs` when the server is running. The raw OpenAPI spec is at `apps/api/docs/swagger.yaml`. + +An external hosted version is available at [core.apidocumentation.com](https://core.apidocumentation.com/guide/swamphacks-core-api). diff --git a/apps/docs/src/api/installation.md b/apps/docs/src/api/installation.md index e69de29b..5ddde1be 100644 --- a/apps/docs/src/api/installation.md +++ b/apps/docs/src/api/installation.md @@ -0,0 +1,89 @@ +# Installation & Setup + +The API runs as a Docker service alongside PostgreSQL and Redis. No local Go installation is required to run the stack. + +## Prerequisites + +- Docker (see [Getting Started](../getting-started.md)) +- A Discord application for OAuth ([Discord Developer Portal](https://discord.com/developers/applications)) + +### Go and API Development Tools + +If you are writing migrations, modifying database queries, or updating the OpenAPI spec, you'll need Go and three CLI tools. + +**1. Install the latest Go** from [go.dev/dl](https://go.dev/dl/). Verify: + +```bash +go version +``` + +**2. Install goose and sqlc:** + +```bash +go install github.com/pressly/goose/v3/cmd/goose@latest +go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest +``` + +**3. Install swag** (custom fork, `v2` branch): + +```bash +git clone -b v2 https://github.com/hieunguyent12/swag.git +cd swag +go install ./cmd/swag +``` + +All three are invoked via `make` targets in `apps/api/`. See [Migrations](migrations.md) and [OpenAPI](openapi.md) for usage. + +## Environment + +Copy the example file: + +```bash +cp apps/api/.env.dev.example apps/api/.env.dev +``` + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | `postgres://postgres:postgres@postgres:5432/coredb` | PostgreSQL connection string (Docker network) | +| `DATABASE_URL_MIGRATION` | `postgres://postgres:postgres@localhost:5432/coredb` | Connection string used when running migrations from the host | +| `REDIS_URL` | `redis://redis:6379` | Redis connection string | +| `ALLOWED_ORIGINS` | — | Comma-separated list of allowed CORS origins | +| `AUTH_DISCORD_CLIENT_ID` | — | Discord OAuth application client ID | +| `AUTH_DISCORD_CLIENT_SECRET` | — | Discord OAuth application client secret | +| `AUTH_DISCORD_REDIRECT_URI` | `http://localhost:8080/auth/callback` | OAuth callback URL (must match Discord app settings) | +| `CORE_BUCKETS_USER_QRCODES_BASE_URL` | — | Base URL for the Cloudflare R2 QR code bucket | +| `COOKIE_DOMAIN` | `localhost` | Domain for session cookies | +| `COOKIE_SECURE` | `false` | Set to `true` in production (requires HTTPS) | +| `CLIENT_URL` | `http://localhost:5173` | Frontend origin, used for redirects | +| `MAX_ACCEPTED_APPLICATIONS` | `500` | Hard cap on accepted applications | +| `ACCEPT_FROM_WAITLIST_COUNT` | `50` | Number of applicants to pull from the waitlist per cycle | +| `ACCEPT_FROM_WAITLIST_PERIOD` | `@every 72h` | Cron-style interval for waitlist processing | + +## Running + +Start the API with its dependencies: + +```bash +make api +# or +docker compose up api +``` + +The API is available at **http://localhost:8080**. + +To also run the background workers: + +```bash +make backend +``` + +## Migrations + +Database migrations run automatically on startup. To run them manually from the host: + +```bash +cd apps/api +go run ./cmd/migrate +``` + +> The `DATABASE_URL_MIGRATION` variable points to `localhost:5432` (host network) rather than the Docker internal hostname, so migrations work when run outside the container. diff --git a/apps/docs/src/api/migrations.md b/apps/docs/src/api/migrations.md index e69de29b..620a52d2 100644 --- a/apps/docs/src/api/migrations.md +++ b/apps/docs/src/api/migrations.md @@ -0,0 +1,98 @@ +# Migrations + +The API uses [Goose](https://github.com/pressly/goose) for database migrations. Migration files live in `internal/db/migrations/` and are embedded into the binary — migrations run automatically on startup. + +## File naming + +Migration files follow the Goose timestamp convention: + +``` +YYYYMMDDHHMMSS_description.sql +``` + +Each file contains an `Up` and `Down` block: + +```sql +-- +goose Up +-- +goose StatementBegin +CREATE TABLE ...; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE ...; +-- +goose StatementEnd +``` + +## Creating a migration + +Generate a new migration file (requires [goose installed](installation.md#api-development-tools)): + +```bash +cd apps/api +goose -dir internal/db/migrations create sql +``` + +This creates a timestamped file in `internal/db/migrations/`. Write your `Up` and `Down` SQL, then commit the file. + +!!! warning + Never modify an existing migration file. If you need to alter a table, create a new migration. + +## Running migrations + +Migrations run automatically when the API starts. To run them manually from the host: + +```bash +cd apps/api +make migrate-up +``` + +This uses `DATABASE_URL_MIGRATION` (pointing to `localhost:5432`) rather than `DATABASE_URL` (the Docker-internal hostname), so it works when run outside the container. + +## Rolling back + +To roll back the last migration: + +```bash +cd apps/api +make migrate-down +``` + +## Migration history + +| File | Description | +|---|---| +| `20250512145328_auth_init` | Auth schema: `users`, `accounts`, `sessions` tables | +| `20250608015747_remove_session_token_and_update_users` | Removed token column, updated users schema | +| `20250608194039_add_roles_to_auth_users` | Added `role` column + `auth_user_role` enum | +| `20250619161938_event_schema` | `events` and `event_roles` tables | +| `20250621222955_create_applications` | `applications` table + `application_status` enum | +| `20250627064521_create_mailing_list` | `event_interest_submissions` table | +| `20250825013123_remove_resume_url_column` | Removed `resume_url` from applications | +| `20250825033231_update_application_trigger` | Updated application `updated_at` trigger | +| `20250905210705_add_preferred_email_column` | Added `preferred_email` to users | +| `20250908055118_add_email_consent_column` | Added `email_consent` to users | +| `20250917044049_add_event_banner` | Added `banner_url` to events | +| `20251002000347_add_get_event_scope_type` | Added event scope enum for query filtering | +| `20251022032904_submitted_by_column_applications` | Added `submitted_by` to applications | +| `20251101211753_teams_table` | `teams` and `team_members` tables | +| `20251106160823_saved_at_trigger` | Added `saved_at` trigger to applications | +| `20251111212010_invitations_and_join_requests` | `team_invitations` and `team_join_requests` tables | +| `20251121165836_add_app_review_columns` | Added reviewer rating columns + `application_review_started` | +| `20251208221807_add_application_waitlist_time_column` | Added `waitlisted_at` to applications | +| `20251215225937_add_bat_runs_schema` | `bat_runs` table + `bat_run_status` enum | +| `20251216200020_add_application_review_finished` | Added review finished flag | +| `20251217065809_remove_application_review_finished_column` | Removed review finished flag | +| `20260116002956_checked_in_time` | Added check-in timestamp to event roles | +| `20260119015108_create_redeemables_tables` | `redeemables` and `user_redemptions` tables | + +## SQLc regeneration + +After modifying a migration or a query file in `internal/db/queries/`, regenerate the Go code: + +```bash +cd apps/api +make generate +``` + +This updates `internal/db/sqlc/` — never edit that directory manually. See `sqlc.yml` for the full codegen configuration. diff --git a/apps/docs/src/api/openapi.md b/apps/docs/src/api/openapi.md new file mode 100644 index 00000000..ba009669 --- /dev/null +++ b/apps/docs/src/api/openapi.md @@ -0,0 +1,151 @@ +# OpenAPI + +The API uses [swag](https://github.com/swaggo/swag) to generate an OpenAPI 3.1 spec from annotations written directly in Go source files. The generated output lives in `apps/api/docs/` and is served at `/docs` by the running API server via [Scalar](https://scalar.com). + +A hosted version of the spec is also available at [core.apidocumentation.com](https://core.apidocumentation.com/guide/swamphacks-core-api). + +--- + +## Installation + +The project uses a custom fork of swag on its `v2` branch. Install it from source: + +```bash +git clone -b v2 https://github.com/hieunguyent12/swag.git +cd swag +go install ./cmd/swag +``` + +> Go must be installed. See [Installation & Setup](installation.md) for instructions. + +Verify: + +```bash +swag --version +``` + +--- + +## Generating the spec + +From `apps/api/`: + +```bash +make openapi-generate +``` + +This runs: + +``` +swag init --dir cmd/api,internal/api/handlers --parseDependency --requiredByDefault -v3.1 +``` + +swag scans `cmd/api/main.go` (for top-level API metadata) and all handler files in `internal/api/handlers/` for route annotations. It writes three output files: + +| File | Description | +|---|---| +| `docs/swagger.yaml` | OpenAPI 3.1 spec (YAML) | +| `docs/swagger.json` | OpenAPI 3.1 spec (JSON) | +| `docs/docs.go` | Embedded Go file for serving the spec at runtime | + +Run this any time you add or change handler annotations. + +## Formatting annotations + +swag can also normalise the annotation comments in-place: + +```bash +make openapi-format +``` + +This runs `swag fmt` over the handler files. Run it before committing annotation changes to keep formatting consistent. + +--- + +## How annotations work + +### API-level metadata + +Top-level metadata is declared in `cmd/api/main.go` above the `main` function: + +```go +// @title SwampHacks Test API +// @version 1.0 +// @description This is SwampHacks' OpenAPI documentation. +``` + +### Route annotations + +Each exported handler method that corresponds to a route gets a block of annotations directly above its signature. swag reads these to build the spec. + +**Example** (`internal/api/handlers/auth.go`): + +```go +// GetMe +// +// @Summary Get Current User +// @Description Get the currently authenticated user's information. +// @Tags Authentication +// @Produce json +// @Param sh_session cookie string true "The authenticated session token/id" +// @Success 200 {object} middleware.UserContext +// @Failure 401 {object} response.ErrorResponse "Unauthenticated" +// @Failure 500 {object} response.ErrorResponse +// @Router /auth/me [get] +func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { +``` + +**Example** (`internal/api/handlers/events.go`): + +```go +// Create a new event +// +// @Summary Create a new event +// @Description Create a new event with the provided details +// @Tags Event +// @Accept json +// @Produce json +// @Param request body CreateEventFields true "Event creation data" +// @Success 201 {object} sqlc.Event "Event created" +// @Failure 400 {object} response.ErrorResponse "Bad request" +// @Router /events [post] +func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { +``` + +### Common annotation fields + +| Annotation | Description | +|---|---| +| `@Summary` | Short one-line description shown in the spec | +| `@Description` | Longer description | +| `@Tags` | Groups the route under a tag in the UI | +| `@Accept` | Request content type (e.g. `json`) | +| `@Produce` | Response content type (e.g. `json`) | +| `@Param` | Parameter definition — see format below | +| `@Success` | Success response with status code and body type | +| `@Failure` | Error response with status code and body type | +| `@Router` | Path and HTTP method — **required** | + +### `@Param` format + +``` +@Param "" +``` + +`` is one of: `query`, `path`, `body`, `header`, `cookie`. + +```go +@Param eventId path string true "Event UUID" +@Param request body CreateEventFields true "Request body" +@Param limit query int false "Page size" +``` + +### Response body types + +Pass a Go struct to `{object}` and swag will reflect its fields into the spec. Types from other packages work as long as `--parseDependency` is set (it is, via `make openapi-generate`): + +```go +@Success 200 {object} sqlc.Event +@Success 201 {object} middleware.UserContext +@Failure 400 {object} response.ErrorResponse +``` diff --git a/apps/docs/src/api/structure.md b/apps/docs/src/api/structure.md index e69de29b..e1484775 100644 --- a/apps/docs/src/api/structure.md +++ b/apps/docs/src/api/structure.md @@ -0,0 +1,89 @@ +# Project Structure + +``` +apps/api/ +├── cmd/ +│ ├── api/ +│ │ └── main.go # API server entry point +│ ├── BAT_worker/ +│ │ ├── main.go # Admission calculator worker +│ │ └── Dockerfile +│ └── email_worker/ +│ ├── main.go # Email delivery worker +│ └── Dockerfile +├── internal/ +│ ├── api/ +│ │ ├── api.go # Router setup, route registration +│ │ ├── handlers/ # HTTP handlers (one file per domain) +│ │ ├── middleware/ +│ │ │ ├── auth.go # Session auth, platform roles +│ │ │ └── events.go # Event-scoped role middleware +│ │ └── response/ # JSON response helpers +│ ├── bat/ # BAT engine logic +│ ├── config/ +│ │ └── config.go # Env-driven configuration structs +│ ├── cookie/ # Session cookie helpers +│ ├── ctxutils/ # Context extraction helpers +│ ├── db/ +│ │ ├── connection.go # pgxpool setup +│ │ ├── errors.go # DB error helpers (unique, not found) +│ │ ├── transaction.go # Transaction manager +│ │ ├── migrations/ # SQL migration files (goose) +│ │ ├── queries/ # Raw SQL query files (sqlc input) +│ │ ├── repository/ # Data access objects +│ │ └── sqlc/ # Generated Go code (do not edit) +│ ├── email/ +│ │ ├── ses.go # AWS SES client +│ │ ├── templates/ # HTML email templates +│ │ └── validation.go # Email address validation +│ ├── logger/ # Zerolog initialization +│ ├── oauth/ +│ │ └── discord.go # Discord OAuth2 exchange + user info +│ ├── parse/ # Generic optional type, safe parsers +│ ├── ptr/ # Pointer helpers +│ ├── services/ # Business logic (one file per domain) +│ ├── storage/ +│ │ ├── r2.go # Cloudflare R2 client +│ │ └── presignable_storage.go +│ ├── tasks/ # Asynq task definitions +│ ├── web/ # HTTP path/query param helpers +│ └── workers/ # Worker process implementations +├── docs/ # Generated Swagger/OpenAPI spec +├── Dockerfile # Production multi-stage build +├── Dockerfile.dev # Development build with Air hot reload +├── go.mod +├── go.sum +└── sqlc.yml # SQLc codegen configuration +``` + +## Key Conventions + +### Handlers + +Handlers live in `internal/api/handlers/` with one file per domain (e.g., `events.go`, `application.go`). Each handler struct receives its service via constructor injection. Handlers are responsible for: + +- Parsing path/query parameters +- Decoding and validating request bodies +- Calling the appropriate service method +- Writing JSON responses + +### Services + +Services live in `internal/services/` with one file per domain. They contain all business logic and coordinate between repositories. Services receive repositories via constructor injection and use the transaction manager for operations that require atomicity. + +### Repositories + +Repositories live in `internal/db/repository/` and provide a type-safe data access layer over the sqlc-generated queries. Each repository wraps a `*sqlc.Queries` and exposes domain-specific methods. Repositories support transactions via `NewTx(tx)`. + +### Generated Code + +`internal/db/sqlc/` is fully generated by sqlc — never edit it directly. To regenerate after changing a query or migration: + +```bash +cd apps/api +make generate +``` + +### Configuration + +All configuration is loaded from environment variables via `internal/config/config.go`. The loader tries `.env.local`, `.env.dev`, and `.env` in order. See [Installation](installation.md) for the full variable reference. diff --git a/apps/docs/src/discord-bot/installation.md b/apps/docs/src/discord-bot/installation.md index e69de29b..daf772a8 100644 --- a/apps/docs/src/discord-bot/installation.md +++ b/apps/docs/src/discord-bot/installation.md @@ -0,0 +1,39 @@ +# Installation + +The Discord bot is a Python service managed with [uv](https://docs.astral.sh/uv/). + +## Prerequisites + +- Python 3.12+ +- uv: `pip install uv` or see the [uv docs](https://docs.astral.sh/uv/getting-started/installation/) +- A Discord bot token ([Discord Developer Portal](https://discord.com/developers/applications)) +- A Google Gemini API key ([Google AI Studio](https://aistudio.google.com/)) + +## Environment + +Copy the example file: + +```bash +cp apps/discord-bot/.env.example apps/discord-bot/.env +``` + +| Variable | Description | +|---|---| +| `DISCORD_TOKEN` | Bot token from the Discord Developer Portal | +| `API_KEY` | API key for authenticating requests to the SwampHacks API | +| `GEMINI_API_KEY` | Google Gemini API key for AI chatbot features | +| `API_URL` | SwampHacks API base URL (`http://localhost:8080` for local dev) | +| `SESSION_COOKIE` | Session cookie value for API authentication | +| `WEBHOOK_URL` | Discord webhook URL for outbound notifications | +| `WEBHOOK_PORT` | Port for the internal webhook listener | +| `EVENT_ID` | The current hackathon event ID | + +## Running + +```bash +cd apps/discord-bot +uv sync +uv run main.py +``` + +The bot connects to Discord and starts an HTTP server (FastAPI) on `WEBHOOK_PORT` for incoming webhooks. diff --git a/apps/docs/src/getting-started.md b/apps/docs/src/getting-started.md index e69de29b..8a78771e 100644 --- a/apps/docs/src/getting-started.md +++ b/apps/docs/src/getting-started.md @@ -0,0 +1,99 @@ +# Getting Started + +This guide covers the prerequisites and steps to get the full SwampHacks stack running locally. + +## Prerequisites + +Install the following tools before continuing. + +### Docker + +Docker runs the full stack (API, web, database, Redis) in containers so you don't need to install each runtime manually. + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (macOS / Windows / WSL) +- Linux: install [Docker Engine](https://docs.docker.com/engine/install/) and the [Compose plugin](https://docs.docker.com/compose/install/) + +Verify: + +```bash +docker --version +docker compose version +``` + +### Node.js (via nvm) + +The web app requires **Node 22.16**. Use [nvm](https://github.com/nvm-sh/nvm) to manage versions. + +You can install [nvm here](https://github.com/nvm-sh/nvm) (MacOS / Linux / WSL). + +If you use windows (bad bad bad) you can install the windows version of [nvm here](https://github.com/coreybutler/nvm-windows). + +> Note: The windows version `should` work exactly the same as the posix compliant nvm, so no worries between system discrepencies. + +You can install `Node 22.16` with the following commands: +``` +nvm install 22.16 +nvm use 22.16 +``` + +Furthermore, if you aren't sure what version you currently are using, you can navigate to `apps/web` and run +`nvm use` which retrieves the version from the `.nvmrc` file and sets it as your current node version. + +### Editor + +[VS Code](https://code.visualstudio.com/) is recommended. The repository is set up with extensions and settings that work well out of the box. Any editor works! + +--- + +## Setup + +### 1. Clone the repository + +```bash +git clone https://github.com/swamphacks/core.git +cd core +``` + +### 2. Configure environment variables + +Each service needs its own `.env` file. Copy the examples to get started: + +```bash +cp apps/api/.env.dev.example apps/api/.env.dev +cp apps/web/.env.example apps/web/.env +cp apps/discord-bot/.env.example apps/discord-bot/.env +``` + +Fill in the required secrets. Most local values are pre-filled in the examples; secrets (Discord OAuth, Gemini API key, etc.) must be obtained from the team. + +See each service's installation guide for a full breakdown of variables: + +- [API →](api/installation.md) +- [Web →](web/installation.md) +- [Discord Bot →](discord-bot/installation.md) + +### 3. Start the stack + +```bash +make local +``` + +This starts the API, web, background workers, PostgreSQL, Redis, and Asynqmon via Docker Compose. + +| Service | URL | +|------------|----------------------------| +| Web | http://localhost:5173 | +| API | http://localhost:8080 | +| Asynqmon | http://localhost:6767 | +| PostgreSQL | `localhost:5432` | +| Redis | `localhost:6379` | + +#### Partial startup + +If you only need part of the stack: + +```bash +make api # API only (+ postgres + redis) +make storage # PostgreSQL + Redis only +make backend # API + background workers + Asynqmon +``` diff --git a/apps/docs/src/infrastructure/cicd.md b/apps/docs/src/infrastructure/cicd.md index e69de29b..dc62f4aa 100644 --- a/apps/docs/src/infrastructure/cicd.md +++ b/apps/docs/src/infrastructure/cicd.md @@ -0,0 +1,142 @@ +# CI/CD + +All automation runs on **GitHub Actions**. Workflows live in `.github/workflows/` and fall into three categories: CI checks, development deployments, and production deployments. + +--- + +## Overview + +| Trigger | What runs | +|---------|-----------| +| `pull_request` → `master` | Lint and test workflows (API, web, discord-bot) | +| `push` → `dev` | Dev build-and-deploy workflows for changed services | +| `push` → `master` | Prod build-and-deploy workflows for changed services; docs deploy | +| `push` (any branch, `apps/api/**`) | `sqlc` CI checks | +| `workflow_dispatch` | Any workflow can be triggered manually | + +All workflows run on `ubuntu-latest` runners. Docker images are published to **GitHub Container Registry** (`ghcr.io`) using `GITHUB_TOKEN` for authentication — no separate registry secret is required. + +--- + +## CI Workflows + +| Workflow file | Job name(s) | Trigger | What it does | +|---|---|---|---| +| `lint-api.yml` | `API Lint` | `pull_request` → `master`, paths `apps/api/**` | Runs `go mod tidy` then `golangci-lint` v2.1.6 | +| `lint-web.yml` | `Web Lint & Format` | `pull_request` → `master`, paths `apps/web/**` | Installs pnpm 10 / Node 22, runs ESLint and Prettier check | +| `lint-discord-bot.yml` | `Discord Bot Lint & Format` | `pull_request` → `master`, paths `apps/discord-bot/**` | Placeholder (linting not yet implemented) | +| `test-web.yml` | `Web unit tests` | `pull_request` → `master`, paths `apps/web/**` | Installs pnpm 10 / Node 22 / Playwright, runs `pnpm test` | +| `sqlc_ci.yml` | `diff`, `vet` | `push` (any branch), paths `apps/api/**` | `sqlc diff` to verify generated code is up to date; `sqlc vet` against a live Postgres 17 instance | +| `docs.yml` | `deploy` | `push` → `master`, paths `apps/docs/**`; `workflow_dispatch` | Installs `mkdocs-material`, runs `mkdocs gh-deploy --force` to publish to GitHub Pages | + +--- + +## Development Deployment Workflows + +All dev workflows trigger on `push` to the `dev` branch (scoped to relevant paths) and support `workflow_dispatch` for manual runs. Images are tagged `:dev` and pushed to `ghcr.io//`. + +| Workflow file | Image built | Compose service | Migration | SSH target | +|---|---|---|---|---| +| `dev-build-deploy-api.yml` | `core-api:dev` (from `apps/api/`) | `api-dev` | Yes — Goose against `DEV_DB_URL` | `API_HOST` | +| `dev-build-deploy-web.yml` | `core-web:dev` (`--target prod`, `linux/amd64`) | `web-dev` | No | `WEB_HOST` | +| `dev-build-deploy-bat-worker.yml` | `core-bat-worker:dev` (`apps/api/cmd/BAT_worker/Dockerfile`, `--target prod`) | `bat-worker-dev` | Yes — Goose against `DEV_DB_URL` | `API_HOST` | +| `dev-build-deploy-email-worker.yml` | `core-email-worker:dev` (`apps/api/cmd/email_worker/Dockerfile`, `--target prod`) | `email-worker-dev` | Yes — Goose against `DEV_DB_URL` | `API_HOST` | +| `dev-build-deploy-discord-bot.yml` | `core-discord-bot:dev` (multi-arch: `linux/amd64,linux/arm64`) | _(push only, no SSH deploy step)_ | No | — | +| `dev-deploy-asynqmon.yml` | _(no build — uses upstream `hibiken/asynqmon`)_ | `asynqmon-dev` | No | `API_HOST` | + +The bat-worker, email-worker, and api workflows all watch the same paths (`apps/api/**`, `infra/docker-compose.api.yml`), so a single push to `apps/api/` triggers all three in parallel. + +--- + +## Production Deployment Workflows + +All prod workflows trigger on `push` to the `master` branch (scoped to relevant paths) and support `workflow_dispatch`. Images are tagged `:latest`. + +| Workflow file | Image built | Compose service | Migration | SSH target | +|---|---|---|---|---| +| `prod-build-deploy-api.yml` | `core-api:latest` | `api` | Yes — Goose against `PROD_DB_URL` | `API_HOST` | +| `prod-build-deploy-web.yml` | `core-web:latest` (`--target prod`, `linux/amd64`) | `web` | No | `WEB_HOST` | +| `prod-build-deploy-bat-worker.yml` | `core-bat-worker:latest` (`apps/api/cmd/BAT_worker/Dockerfile`, `--target prod`) | `bat-worker` | Yes — Goose against `PROD_DB_URL` | `API_HOST` | +| `prod-build-deploy-email-worker.yml` | `core-email-worker:latest` (`apps/api/cmd/email_worker/Dockerfile`, `--target prod`) | `email-worker` | Yes — Goose against `DEV_DB_URL`* | `API_HOST` | +| `prod-deploy-asynqmon.yml` | _(no build — uses upstream `hibiken/asynqmon`)_ | `asynqmon` | No | `API_HOST` | + +\* `prod-build-deploy-email-worker.yml` currently references `DEV_DB_URL` in its migration step — this appears to be a bug in the workflow. + +--- + +## Caddy Deployment + +`deploy-caddy.yml` is **manual only** (`workflow_dispatch`). It has no build step — Caddy runs from the `caddy-cloudflare` image already present on the droplet. The job SSHs into `API_HOST`, pulls the infra repo to `master`, fetches secrets from Infisical (`--env=master`, path `/api`), then pulls and recreates the `caddy` container via `docker-compose.api.yml`. + +Run this workflow whenever the `Caddyfile` or Caddy configuration changes. + +--- + +## Build and Deploy Pattern + +Every service that builds a Docker image follows this three-job sequence: + +``` +build-and-push → run-migrations (API services only) → deploy +``` + +**1. `build-and-push`** + +Logs in to GHCR using `GITHUB_TOKEN`, builds the image, and pushes it: + +```bash +docker build -t ghcr.io//core-api:dev ./apps/api +docker push ghcr.io//core-api:dev +``` + +The web image specifies `--platform linux/amd64` and `--target prod`. The discord-bot image uses `docker buildx` for multi-arch (`linux/amd64,linux/arm64`). + +**2. `run-migrations`** (API, BAT worker, email worker only) + +Installs Goose from the official install script and runs all pending migrations: + +```bash +goose -dir ./apps/api/internal/db/migrations postgres "$DB_URL" up +``` + +This job depends on `build-and-push` completing successfully before it runs. + +**3. `deploy`** + +Depends on both `build-and-push` and `run-migrations`. Uses `appleboy/ssh-action` to connect to the target droplet as `root`, then: + +```bash +cd /root/core/infra +git fetch && git checkout dev && git reset --hard origin/dev && git pull + +# Fetch secrets from Infisical and write to .env file +export INFISICAL_TOKEN=$(infisical login --method=universal-auth \ + --client-id='...' --client-secret='...' --silent --plain) + +infisical export --token=$INFISICAL_TOKEN --env=dev \ + --format=dotenv --path="/api" --projectId='...' \ + > ./secrets/.env.dev.api + +# Pull the new image and recreate the container +docker compose -f docker-compose.api.yml pull api-dev +docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate api-dev +``` + +For production, `dev` is replaced with `master` (or `main` for the web droplet), `:dev` tags become `:latest`, and the Infisical `--env` is `prod`. + +--- + +## Required GitHub Secrets + +| Secret | Used by | +|--------|---------| +| `GITHUB_TOKEN` | All build workflows — authenticates `docker login` to GHCR (automatically provided) | +| `API_HOST` | All workflows that SSH into the API droplet | +| `API_PASSWORD` | SSH password for `root@API_HOST` | +| `WEB_HOST` | Workflows that SSH into the web droplet | +| `WEB_PASSWORD` | SSH password for `root@WEB_HOST` | +| `DEV_DB_URL` | Goose migration connection string for the dev database | +| `PROD_DB_URL` | Goose migration connection string for the production database | +| `INFISICAL_CLIENT_ID` | Infisical universal-auth client ID | +| `INFISICAL_CLIENT_SECRET` | Infisical universal-auth client secret | +| `INFISICAL_PROJECT_ID` | Infisical project ID used when exporting secrets | diff --git a/apps/docs/src/infrastructure/digitalocean.md b/apps/docs/src/infrastructure/digitalocean.md index e69de29b..03eb75c2 100644 --- a/apps/docs/src/infrastructure/digitalocean.md +++ b/apps/docs/src/infrastructure/digitalocean.md @@ -0,0 +1,186 @@ +# DigitalOcean + +SwampHacks Core runs on two plain DigitalOcean droplets — no managed databases, no App Platform, no Kubernetes. Each droplet runs Docker Compose directly. This setup is an amalgamation of decisions made over time; it works, but it is more manually operated than a fully managed solution would be. + +--- + +## Droplets + +| Droplet | Secret ref | What runs on it | +|---------|-----------|-----------------| +| **API droplet** | `API_HOST` | API (prod + dev), both background workers (prod + dev), Redis (prod + dev), Asynqmon (prod + dev), Caddy | +| **Web droplet** | `WEB_HOST` | Web frontend (prod + dev), Discord bot, Caddy | + +SSH access uses `root` with a password stored in GitHub Actions secrets (`API_PASSWORD` / `WEB_PASSWORD`). There is no key-based auth in the current workflows. + +--- + +## Environments + +Both droplets run **prod** and **dev** side-by-side as separate containers within the same Compose stack: + +- **Production** — triggered by pushes to `master`. Images tagged `:latest`. Secrets pulled from the `prod` Infisical environment. +- **Development** — triggered by pushes to `dev`. Images tagged `:dev`. Secrets pulled from the `dev` Infisical environment. + +The root `docker-compose.yml` and `infra/docker-compose.dev.yml` are **not used on the droplets**. Those exist for local development only (bind mounts, local Postgres, source-built images). The droplet-specific files are `infra/docker-compose.api.yml` and `infra/docker-compose.web.yml`. + +--- + +## What Runs Where + +### API Droplet — `docker-compose.api.yml` + +| Service | Image | Port | +|---------|-------|------| +| `api` | `ghcr.io/swamphacks/core-api:latest` | 8080 (host) | +| `api-dev` | `ghcr.io/swamphacks/core-api:dev` | 8081 (host) | +| `email-worker` | `ghcr.io/swamphacks/core-email-worker:latest` | — | +| `email-worker-dev` | `ghcr.io/swamphacks/core-email-worker:dev` | — | +| `bat-worker` | `ghcr.io/swamphacks/core-bat-worker:latest` | — | +| `bat-worker-dev` | `ghcr.io/swamphacks/core-bat-worker:dev` | — | +| `redis` | `redis:8.2.1-alpine` | 6379 (host) | +| `redis-dev` | `redis:8.2.1-alpine` | 6380 (host) | +| `asynqmon` | `hibiken/asynqmon:latest` | 6767 (host) | +| `asynqmon-dev` | `hibiken/asynqmon:latest` | 6768 (host) | +| `caddy` | `ghcr.io/caddybuilds/caddy-cloudflare:latest` | 80, 443 | + +Redis prod is capped at 500 MB; Redis dev at 200 MB. Both use `allkeys-lru` eviction. + +### Web Droplet — `docker-compose.web.yml` + +| Service | Image | Network | +|---------|-------|---------| +| `web` | `ghcr.io/swamphacks/core-web:latest` | `caddy_net` (internal only, port 80) | +| `web-dev` | `ghcr.io/swamphacks/core-web:dev` | `caddy_net` (internal only, port 80) | +| `discord` | `ghcr.io/swamphacks/core-discord:latest` | default | +| `caddy` | `ghcr.io/caddybuilds/caddy-cloudflare:latest` | `caddy_net`, ports 80 / 443 | + +Web containers are not bound to host ports. Caddy reaches them over the shared `caddy_net` Docker bridge network. + +--- + +## Docker Images and Registry + +All application images are built by GitHub Actions and pushed to the **GitHub Container Registry** (GHCR) under `ghcr.io/swamphacks/`: + +| Image | Tags | +|-------|------| +| `ghcr.io/swamphacks/core-api` | `:latest`, `:dev` | +| `ghcr.io/swamphacks/core-web` | `:latest`, `:dev` | +| `ghcr.io/swamphacks/core-email-worker` | `:latest`, `:dev` | +| `ghcr.io/swamphacks/core-bat-worker` | `:latest`, `:dev` | +| `ghcr.io/swamphacks/core-discord` | `:latest`, `:dev` | + +Caddy (`ghcr.io/caddybuilds/caddy-cloudflare`) and Redis (`redis:8.2.1-alpine`) are pulled directly from their public registries. GHCR authentication in workflows uses `GITHUB_TOKEN` — no additional credentials are required. + +--- + +## Deployment Process + +Each service has a dedicated workflow. The naming convention is: + +``` +.github/workflows/ + dev-build-deploy-api.yml + dev-build-deploy-web.yml + prod-build-deploy-api.yml + prod-build-deploy-web.yml + prod-build-deploy-bat-worker.yml + prod-build-deploy-email-worker.yml + deploy-caddy.yml +``` + +### Standard deploy steps (API/worker workflows) + +1. **Build and push** — The image is built on the Actions runner and pushed to GHCR. +2. **Run migrations** — Goose migrations are applied against the target database from the runner using `PROD_DB_URL` or `DEV_DB_URL`. +3. **Deploy** — The runner SSHes into the droplet via `appleboy/ssh-action` and runs: + +```bash +cd /root/core/infra +git fetch +git checkout # master or dev, depending on workflow +git reset --hard origin/ +git pull + +export INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id='...' \ + --client-secret='...' \ + --silent \ + --plain) + +infisical export \ + --token=$INFISICAL_TOKEN \ + --env= \ + --format=dotenv \ + --path="/api" \ + --projectId='...' \ + > ./secrets/.env.api # or .env.dev.api / .env.web / .env.dev.web + +docker compose -f docker-compose.api.yml pull +docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate +``` + +The `--no-deps --force-recreate` flags ensure only the targeted service is restarted; other running containers are left alone. + +### Web deploy + +The web workflows follow the same pattern but target `WEB_HOST`/`WEB_PASSWORD` and use `docker-compose.web.yml`. + +### Key differences between infra Compose files and the local dev compose + +| Aspect | Local (`docker-compose.yml`) | Droplet (`docker-compose.api.yml` / `.web.yml`) | +|--------|------------------------------|-------------------------------------------------| +| Images | Built from source | Pre-built, pulled from GHCR | +| Env vars | Local `.env` files or defaults | Written to `infra/secrets/` at deploy time by Infisical | +| Bind mounts | Yes (source code) | None — containers are entirely image-based | +| Postgres | Local container | External managed database (URL from secrets) | +| Caddy | Not included | Runs on the droplet, manages TLS | + +--- + +## Caddy + +Caddy handles TLS termination and reverse proxying on both droplets. It uses the `caddy-cloudflare` image, which bundles the Cloudflare DNS plugin for ACME DNS-01 challenges. The Cloudflare API token is loaded from `infra/secrets/.env.cf`. + +### API droplet — `Caddyfile.api` + +| Host | Upstream | +|------|----------| +| `api.swamphacks.com` | `api:8080` | +| `dev-api.swamphacks.com` | `api-dev:8080` | +| `asynqmon.swamphacks.com` | `asynqmon:6767` | +| `dev-asynqmon.swamphacks.com` | `asynqmon-dev:6767` | + +### Web droplet — `Caddyfile.web` + +| Host | Upstream | +|------|----------| +| `app.swamphacks.com` | `web:80` | +| `dev-app.swamphacks.com` | `web-dev:80` | + +All virtual hosts set `Strict-Transport-Security`, `X-Content-Type-Options`, `X-Frame-Options`, and `Referrer-Policy` headers, and enable gzip/zstd compression. + +### Updating Caddy + +The `deploy-caddy.yml` workflow is **manual dispatch only** (`workflow_dispatch`). It SSHes into the API droplet, pulls the latest `infra` branch, refreshes secrets from Infisical, then runs: + +```bash +docker compose -f docker-compose.api.yml pull caddy +docker compose -f docker-compose.api.yml up -d --no-deps --force-recreate caddy +``` + +The `Caddyfile.api` is mounted into the container as a bind mount (`./Caddyfile.api:/etc/caddy/Caddyfile`), so updating the file on the droplet (via the git pull) and recreating the container is all that is needed. + +There is no equivalent manual workflow for Caddy on the web droplet — that container is brought up as part of the web Compose stack. + +--- + +## Secrets on the Droplet + +Secrets are not stored on the droplet long-term. At every deploy, the workflow authenticates to Infisical using `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` (stored in GitHub Actions), exports the relevant secret path to a dotenv file under `infra/secrets/`, and that file is consumed by Docker Compose via `env_file`. + +For the web stack, a standalone helper script (`infra/fetch-web-secrets.sh`) can be run manually on the droplet to regenerate `secrets/.env.dev.web` without triggering a full deploy. It authenticates against Infisical's REST API directly using `curl` and `jq`. + +See [Secrets / Infisical](secrets.md) for full details on the secret layout and path conventions. diff --git a/apps/docs/src/infrastructure/docker.md b/apps/docs/src/infrastructure/docker.md index e69de29b..8c0b5c38 100644 --- a/apps/docs/src/infrastructure/docker.md +++ b/apps/docs/src/infrastructure/docker.md @@ -0,0 +1,168 @@ +# Docker + +The project uses Docker Compose for both local development and production deployment. The two environments share the same core services but differ in how images are sourced, how traffic is routed, and how hot-reload is handled. + +--- + +## Development vs Production + +| Concern | Development (`docker-compose.yml`) | Production (`infra/docker-compose.api.yml`, `infra/docker-compose.web.yml`) | +|---|---|---| +| Image source | Built locally from Dockerfiles | Pulled from `ghcr.io/swamphacks/` | +| API hot-reload | Air (`Dockerfile.dev`) with bind mount | Pre-built binary in `alpine` image | +| Web hot-reload | Vite dev server (`target: dev`) with bind mount | `serve` serving compiled `dist/` | +| Postgres | `postgres:17.4` container, named volume | Not included — assumed external or managed separately | +| Redis | `redis:8.2.1-alpine`, health-checked | `redis:8.2.1-alpine`, memory-capped, persistence optional | +| TLS | None — direct port exposure | Caddy with Cloudflare DNS challenge | +| Env files | `./apps/api/.env.dev` | `./infra/secrets/.env.api`, `.env.web`, `.env.cf` | + +The production side is split across two compose files deployed on separate hosts: + +- **`infra/docker-compose.api.yml`** — API server, workers, Redis, Caddy (API), Asynqmon. Runs both a `latest` (production) and `dev`-tagged stack side by side on the same host. +- **`infra/docker-compose.web.yml`** — Web frontend, Discord bot, Caddy (web). + +--- + +## Service Breakdown + +### Development (`docker-compose.yml`) + +| Service | Image / Build | Ports | Depends On | +|---|---|---|---| +| `api` | `./apps/api` via `Dockerfile.dev` | `8080:8080` | `postgres`, `redis` | +| `bat_worker` | `./apps/api` via `cmd/email_worker/Dockerfile` (`target: dev`) | — | `redis` | +| `email_worker` | `./apps/api` via `cmd/email_worker/Dockerfile` (`target: dev`) | — | `redis` | +| `web` | `./apps/web` via `Dockerfile` (`target: dev`) | `5173:5173` | `api` | +| `asynqmon` | `hibiken/asynqmon:latest` | `6767:6767` | `redis` | +| `postgres` | `postgres:17.4` | `5432:5432` | — | +| `redis` | `redis:8.2.1-alpine` | `6379:6379` | — | + +### Production API host (`infra/docker-compose.api.yml`) + +Two full stacks run concurrently — one tagged `latest` (production) and one tagged `dev`. + +| Service | Image | Ports | Depends On | +|---|---|---|---| +| `api` | `ghcr.io/swamphacks/core-api:latest` | `8080:8080` | `redis` | +| `email-worker` | `ghcr.io/swamphacks/core-email-worker:latest` | — | `redis` | +| `bat-worker` | `ghcr.io/swamphacks/core-bat-worker:latest` | — | `redis` | +| `redis` | `redis:8.2.1-alpine` | `6379:6379` | — | +| `asynqmon` | `hibiken/asynqmon:latest` | `6767:6767` | `redis` | +| `api-dev` | `ghcr.io/swamphacks/core-api:dev` | `8081:8080` | `redis-dev` | +| `email-worker-dev` | `ghcr.io/swamphacks/core-email-worker:dev` | — | `redis-dev` | +| `bat-worker-dev` | `ghcr.io/swamphacks/core-bat-worker:dev` | — | `redis-dev` | +| `redis-dev` | `redis:8.2.1-alpine` | `6380:6379` | — | +| `asynqmon-dev` | `hibiken/asynqmon:latest` | `6768:6767` | `redis-dev` | +| `caddy` | `ghcr.io/caddybuilds/caddy-cloudflare:latest` | `80:80`, `443:443` | `api`, `api-dev` | + +### Production web host (`infra/docker-compose.web.yml`) + +| Service | Image | Exposed | Depends On | +|---|---|---|---| +| `web` | `ghcr.io/swamphacks/core-web:latest` | `80` (internal) | — | +| `web-dev` | `ghcr.io/swamphacks/core-web:dev` | `80` (internal) | — | +| `discord` | `ghcr.io/swamphacks/core-discord:latest` | — | — | +| `caddy` | `ghcr.io/caddybuilds/caddy-cloudflare:latest` | `80:80`, `443:443` | `web-dev` | + +The web containers expose port 80 only to the internal `caddy_net` bridge network — they are never bound to the host directly. + +--- + +## Dockerfile Patterns + +### API — production (`apps/api/Dockerfile`) + +Two-stage build. The `builder` stage uses `golang:1.25-alpine` with `build-base` and `ca-certificates` to compile a statically linked binary: + +```dockerfile +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/api +``` + +The final image is bare `alpine:latest` with only `ca-certificates` and the compiled binary. No Go toolchain ships to production. + +### API — development (`apps/api/Dockerfile.dev`) + +Single stage using `golang:latest`. Installs [Air](https://github.com/air-verse/air) and sets it as the entrypoint. The entire `./apps/api` directory is bind-mounted at `/app`, so Air watches for source changes and rebuilds in place without restarting the container. + +### Workers — BAT and email (`cmd/BAT_worker/Dockerfile`, `cmd/email_worker/Dockerfile`) + +Both share the same three-stage pattern: + +- **`base`** — `golang:1.25-alpine`, downloads modules, copies source. +- **`dev`** — installs Air, runs with the appropriate Air config. Used by the root `docker-compose.yml` via `target: dev`. +- **`prod`** — compiles a statically linked binary with `CGO_ENABLED=0`, adds `ca-certificates`, runs the binary directly. + +### Web (`apps/web/Dockerfile`) + +Four stages using `node:22.16.0-slim` with `pnpm`: + +- **`base`** — installs dependencies via `pnpm install --frozen-lockfile`. +- **`dev`** — exposes `5173`, runs `pnpm run dev --host 0.0.0.0`. Used in the root compose with a bind mount. +- **`build`** — runs `pnpm run build`, producing `/app/dist`. +- **`prod`** — copies `dist/` into a fresh `node:22.16.0-slim` image, serves it with `serve` on port 80. An `entrypoint.sh` script runs first to inject runtime configuration. + +--- + +## Caddy as Reverse Proxy + +Production uses a custom Caddy image with the [Cloudflare DNS plugin](https://github.com/caddy-dns/cloudflare) (`ghcr.io/caddybuilds/caddy-cloudflare`) so that TLS certificates are issued via DNS-01 challenge without requiring inbound port 80 to be reachable by Let's Encrypt. + +### `Caddyfile.api` + +Routes four domains on the API host. All blocks set HSTS, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, and enable gzip/zstd compression. + +| Domain | Upstream | Purpose | +|---|---|---| +| `api.swamphacks.com` | `api:8080` | Production API | +| `dev-api.swamphacks.com` | `api-dev:8080` | Dev-tagged API | +| `asynqmon.swamphacks.com` | `asynqmon:6767` | Production queue dashboard | +| `dev-asynqmon.swamphacks.com` | `asynqmon-dev:6767` | Dev queue dashboard | + +Each `reverse_proxy` block forwards `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Port`, and `X-Forwarded-Proto` headers upstream. + +### `Caddyfile.web` + +Routes two domains on the web host. Web containers are only reachable through the internal `caddy_net` Docker network. + +| Domain | Upstream | Purpose | +|---|---|---| +| `app.swamphacks.com` | `web:80` | Production frontend | +| `dev-app.swamphacks.com` | `web-dev:80` | Dev-tagged frontend | + +--- + +## Makefile Targets + +All targets run `docker compose` against the root `docker-compose.yml`. Run from the repository root. + +| Target | Command | What it starts | +|---|---|---| +| `make local` | `docker compose up` | All services — full local stack | +| `make api` | `docker compose up api` | API only | +| `make bat` | `docker compose up api bat_worker asynqmon` | API + BAT worker + Asynqmon dashboard | +| `make backend` | `docker compose up api email_worker bat_worker asynqmon` | API + both workers + Asynqmon | +| `make storage` | `docker compose up postgres redis` | Postgres + Redis only | + +`make storage` is useful when running the API from the host with `go run` directly, keeping only the infrastructure containers managed by Docker. + +--- + +## Volume Strategy + +| Volume | Used by | Purpose | +|---|---|---| +| `postgres_data` | `postgres` (dev) | Persists database across container restarts | +| `redis_data` | `redis` (prod) | Persists Redis AOF/RDB data in production | +| `redis_data_dev` | `redis-dev` (prod dev stack) | Separate persistence for the dev Redis instance | +| `caddy_data` | `caddy` | Stores TLS certificates issued by Let's Encrypt | +| `caddy_config` | `caddy` | Caddy runtime configuration | + +### Bind mounts in development + +| Mount | Service | Effect | +|---|---|---| +| `./apps/api:/app` | `api`, `bat_worker`, `email_worker` | Source changes immediately visible to Air — no rebuild required | +| `./apps/web:/app:cached` | `web` | Source changes picked up by Vite HMR | +| `/app/node_modules` | `web` | Anonymous volume prevents the host `node_modules` from shadowing the container's installed packages | + +In production, no bind mounts are used. All application code is baked into the image at build time. diff --git a/apps/docs/src/infrastructure/index.md b/apps/docs/src/infrastructure/index.md index e69de29b..a4f4b8b7 100644 --- a/apps/docs/src/infrastructure/index.md +++ b/apps/docs/src/infrastructure/index.md @@ -0,0 +1,83 @@ +# Infrastructure Overview + +SwampHacks Core runs on two DigitalOcean droplets — one for the API stack, one for the web stack — each hosting Docker containers behind a Caddy reverse proxy. Secrets are managed by Infisical and injected at deploy time. All deployments are driven by GitHub Actions. + +--- + +## Components + +| Component | Role | +|-----------|------| +| **DigitalOcean** | Hosts the API droplet and the web droplet | +| **Docker / Docker Compose** | Runs all services as containers on each droplet | +| **Caddy** | TLS termination and reverse proxy; certificates issued via Cloudflare DNS challenge | +| **Infisical** | Secrets store; secrets are pulled at deploy time and written to `.env` files | +| **GitHub Actions** | CI/CD pipelines that build images, run migrations, and deploy to each droplet | +| **GHCR** | Docker images are published to GitHub Container Registry (`ghcr.io/swamphacks/`) | + +--- + +## Services and Hosts + +### API Droplet (`docker-compose.api.yml`) + +| Service | Image tag | Internal port | Public host | +|---------|-----------|---------------|-------------| +| `api` (prod) | `core-api:latest` | 8080 | `api.swamphacks.com` | +| `api-dev` (dev) | `core-api:dev` | 8081 | `dev-api.swamphacks.com` | +| `email-worker` (prod) | `core-email-worker:latest` | — | — | +| `email-worker-dev` | `core-email-worker:dev` | — | — | +| `bat-worker` (prod) | `core-bat-worker:latest` | — | — | +| `bat-worker-dev` | `core-bat-worker:dev` | — | — | +| `redis` (prod) | `redis:8.2.1-alpine` | 6379 | — | +| `redis-dev` | `redis:8.2.1-alpine` | 6380 | — | +| `asynqmon` (prod) | `hibiken/asynqmon` | 6767 | `asynqmon.swamphacks.com` | +| `asynqmon-dev` | `hibiken/asynqmon` | 6768 | `dev-asynqmon.swamphacks.com` | +| `caddy` | `caddy-cloudflare` | 80 / 443 | all of the above | + +### Web Droplet (`docker-compose.web.yml`) + +| Service | Image tag | Public host | +|---------|-----------|-------------| +| `web` (prod) | `core-web:latest` | `app.swamphacks.com` | +| `web-dev` | `core-web:dev` | `dev-app.swamphacks.com` | +| `discord` | `core-discord:latest` | — | +| `caddy` | `caddy-cloudflare` | all of the above | + +--- + +## Environments + +Both droplets run **prod** and **dev** side-by-side: + +- **Production** — triggered by pushes to `master`. Images are tagged `:latest`. Secrets come from the `prod` Infisical environment. +- **Development** — triggered by pushes to `dev`. Images are tagged `:dev`. Secrets come from the `dev` Infisical environment. + +The local development environment (`docker-compose.yml` at the repo root) is separate — it builds images from source and uses a local Postgres instance. It is not used on the droplets. + +--- + +## CI/CD Workflow Structure + +Each service has a dedicated GitHub Actions workflow file. The naming convention reflects the environment and service: + +``` +dev-build-deploy-.yml # pushes to dev branch +prod-build-deploy-.yml # pushes to master branch +deploy-caddy.yml # manual dispatch only +``` + +Every deploy workflow follows the same three-step pattern: + +1. **Build & push** — Docker image built and pushed to GHCR. +2. **Migrate** — Goose migrations run against the target database (API workflows only). +3. **Deploy** — SSH into the droplet, pull secrets from Infisical, pull the new image, recreate the container. + +--- + +## Further Reading + +- [Docker](docker.md) — Compose file structure, image naming, and how services are organized. +- [Secrets / Infisical](secrets.md) — How secrets are stored, pulled, and injected into containers. +- [DigitalOcean](digitalocean.md) — Droplet setup and SSH access. +- [CI/CD](cicd.md) — Workflow details, required GitHub secrets, and the deploy pipeline. diff --git a/apps/docs/src/infrastructure/secrets.md b/apps/docs/src/infrastructure/secrets.md index e69de29b..8d58f325 100644 --- a/apps/docs/src/infrastructure/secrets.md +++ b/apps/docs/src/infrastructure/secrets.md @@ -0,0 +1,201 @@ +# Secrets Management (Infisical) + +[Infisical](https://infisical.com) is the centralised secrets manager for SwampHacks Core. No secret values are stored in the repository. All secrets live in Infisical and are pulled at deploy time. + +--- + +## Why Infisical + +- **No secrets in the repo.** `.env` files that contain real values are git-ignored. Only `.env.example` / `.env.dev.example` files (with blank or placeholder values) are committed. +- **Centralised management.** All secrets for all services and environments are in one place. Rotating a secret means updating it in Infisical once — the next deploy picks it up automatically. +- **Per-environment isolation.** Infisical organises secrets by environment (`dev`, `prod`) and by path (`/api`, `/web`). Each deploy workflow targets the correct combination. + +--- + +## How Secrets Flow into the System + +``` +Infisical (source of truth) + │ + │ pulled via `infisical export` using a machine-identity token + ▼ + .env file written to infra/secrets/ on the droplet + │ + │ mounted into Docker containers via docker-compose env_file + ▼ + Running container has environment variables available +``` + +The GitHub Actions workflows drive this process. The deploy step of each workflow: + +1. SSHes into the target droplet. +2. Authenticates with Infisical using a machine identity (`universal-auth`). +3. Exports the secrets for the target environment and path to a `.env` file inside `infra/secrets/`. +4. Pulls the new Docker image and recreates the container, which picks up the freshly written `.env` file. + +--- + +## GitHub Actions Secrets + +The following secrets must be configured in the GitHub repository (`Settings → Secrets and variables → Actions`) for the deploy workflows to function: + +| Secret | Used by | Purpose | +|--------|---------|---------| +| `INFISICAL_CLIENT_ID` | all deploy workflows | Infisical machine identity client ID | +| `INFISICAL_CLIENT_SECRET` | all deploy workflows | Infisical machine identity client secret | +| `INFISICAL_PROJECT_ID` | all deploy workflows | Infisical project identifier | +| `API_HOST` | API workflows | SSH host for the API droplet | +| `API_PASSWORD` | API workflows | SSH password for the API droplet | +| `WEB_HOST` | web workflows | SSH host for the web droplet | +| `WEB_PASSWORD` | web workflows | SSH password for the web droplet | +| `DEV_DB_URL` | `dev-build-deploy-api.yml` | Postgres connection string for running dev migrations | +| `PROD_DB_URL` | `prod-build-deploy-api.yml` | Postgres connection string for running prod migrations | + +`INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`, and `INFISICAL_PROJECT_ID` are the only secrets that originate from Infisical itself. All other application secrets are stored in Infisical and never put directly in GitHub. + +--- + +## The Deploy Pattern (Infisical Token Exchange) + +Every deploy workflow follows the same two-step Infisical pattern on the server: + +```bash +# Step 1 — obtain a short-lived access token using the machine identity +export INFISICAL_TOKEN=$(infisical login \ + --method=universal-auth \ + --client-id='${{ secrets.INFISICAL_CLIENT_ID }}' \ + --client-secret='${{ secrets.INFISICAL_CLIENT_SECRET }}' \ + --silent \ + --plain) + +# Step 2 — export secrets for the target environment and path to a dotenv file +infisical export \ + --token=$INFISICAL_TOKEN \ + --env=dev \ # or prod + --format=dotenv \ + --path="/api" \ # or /web + --projectId='${{ secrets.INFISICAL_PROJECT_ID }}' \ + > ./secrets/.env.dev.api # output file name varies per service/environment +``` + +The four output files written to `infra/secrets/` are: + +| File | Environment | Service | +|------|-------------|---------| +| `secrets/.env.dev.api` | dev | API | +| `secrets/.env.api` | prod | API | +| `secrets/.env.dev.web` | dev | web | +| `secrets/.env.web` | prod | web | + +--- + +## The fetch-web-secrets.sh Script + +`infra/fetch-web-secrets.sh` is an alternative mechanism for fetching web secrets directly via the Infisical REST API rather than the CLI. It is intended for environments where the Infisical CLI is not installed (e.g. a CI runner that only has `curl` and `jq`). + +**What it does:** + +1. Requires `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` to be set in the environment. `WORKSPACE_SLUG` defaults to `swamphacks-core` and `ENVIRONMENT` defaults to `dev`. +2. Calls `POST https://us.infisical.com/api/v1/auth/universal-auth/login` to exchange the client credentials for a short-lived `accessToken`. +3. Calls `GET https://us.infisical.com/api/v3/secrets/raw` with `secretPath=/web` to retrieve all secrets (including any imported secret sets) and pipes them through `jq` to produce `KEY=VALUE` lines. +4. Writes the result to `./secrets/.env.dev.web`. + +**When to use it:** The deploy workflows use the Infisical CLI directly (see above). This script is useful for one-off manual secret refreshes or in environments where the CLI cannot be installed. + +--- + +## Local Development + +Infisical is **not** required for local development. Each service ships an example env file with safe placeholder values. Copy it and fill in only what you need: + +### API (`apps/api`) + +```bash +cp apps/api/.env.dev.example apps/api/.env +``` + +Key variables: + +| Variable | Default / Example | Notes | +|----------|-------------------|-------| +| `DATABASE_URL` | `postgres://postgres:postgres@postgres:5432/coredb` | Used inside the container | +| `DATABASE_URL_MIGRATION` | `postgres://postgres:postgres@localhost:5432/coredb` | Used from the host when running migrations directly | +| `REDIS_URL` | `redis://redis:6379` | | +| `ALLOWED_ORIGINS` | _(empty)_ | Comma-separated list of allowed CORS origins | +| `AUTH_DISCORD_CLIENT_ID` | _(empty)_ | Discord OAuth application client ID | +| `AUTH_DISCORD_CLIENT_SECRET` | _(empty)_ | Discord OAuth application client secret | +| `AUTH_DISCORD_REDIRECT_URI` | `http://localhost:8080/auth/callback` | | +| `CORE_BUCKETS_USER_QRCODES_BASE_URL` | _(empty)_ | Cloudflare R2 public base URL for QR code assets | +| `COOKIE_DOMAIN` | `localhost` | | +| `COOKIE_SECURE` | `false` | Set to `true` in production | +| `CLIENT_URL` | `http://localhost:5173` | | +| `MAX_ACCEPTED_APPLICATIONS` | `500` | Waitlist configuration | +| `ACCEPT_FROM_WAITLIST_COUNT` | `50` | | +| `ACCEPT_FROM_WAITLIST_PERIOD` | `@every 72h` | Cron-style period | + +### Web (`apps/web`) + +```bash +cp apps/web/.env.example apps/web/.env.local +``` + +| Variable | Example | Notes | +|----------|---------|-------| +| `VITE_BASE_API_URL` | `https://api.swamphacks.com` | Point to `http://localhost:8080` for local API | +| `VITE_DISCORD_OAUTH_CLIENT_ID` | _(empty)_ | Discord OAuth application client ID | +| `VITE_ALLOWED_HOSTS` | `[""]` | JSON array of allowed host strings | + +### Discord Bot (`apps/discord-bot`) + +```bash +cp apps/discord-bot/.env.example apps/discord-bot/.env +``` + +| Variable | Notes | +|----------|-------| +| `DISCORD_TOKEN` | Bot token from the Discord Developer Portal | +| `API_KEY` | Internal API key for authenticating against the Core API | +| `GEMINI_API_KEY` | Google Gemini API key | +| `API_URL` | Core API base URL (`https://api.swamphacks.com` or `http://localhost:8080`) | +| `SESSION_COOKIE` | Session cookie value for authenticated API calls | +| `WEBHOOK_URL` | Incoming webhook URL | +| `WEBHOOK_PORT` | Port the webhook listener binds to | +| `EVENT_ID` | Identifier for the active event | + +--- + +## Adding a New Secret + +Follow these steps in order: + +1. **Add the secret in Infisical.** Navigate to the correct project, environment (`dev` / `prod`), and path (`/api`, `/web`, etc.) and create the secret. + +2. **Add the GitHub Actions secret** (only if the secret is needed during the build or migration steps, not just at runtime). Go to `Settings → Secrets and variables → Actions` in the repository and add the secret. If it is only needed at runtime inside the container, skip this step — it will flow through Infisical at deploy time. + +3. **Add the variable to the relevant `.env.example` file.** Add the key with an empty or placeholder value and a short comment describing its purpose. This is the in-repo documentation for what variables a service expects. + + ```bash + # My new secret — obtained from the relevant third-party service dashboard + MY_NEW_SECRET= + ``` + +4. **Document it** in the relevant service's installation or configuration page (e.g. `api/`, `web/`, `discord-bot/` sections of these docs). + +5. **Test locally** by adding the value to your local `.env` file. Do not commit the file with a real value. + +--- + +## The `infra/secrets/` Directory + +The `infra/secrets/` directory on the server is where all generated `.env` files land. It is git-ignored. The only file committed in that directory is `secrets/README.md`, which describes the expected contents. + +At runtime the directory holds: + +| File | Contents | +|------|----------| +| `secrets/.env.dev.api` | Dev API secrets (exported from Infisical `/api`, env `dev`) | +| `secrets/.env.api` | Prod API secrets (exported from Infisical `/api`, env `prod`) | +| `secrets/.env.dev.web` | Dev web secrets (exported from Infisical `/web`, env `dev`) | +| `secrets/.env.web` | Prod web secrets (exported from Infisical `/web`, env `prod`) | + +These files are referenced by the Docker Compose files via `env_file` directives and are never pushed to the repository. diff --git a/apps/docs/src/web/api-integration.md b/apps/docs/src/web/api-integration.md index e69de29b..342e6c16 100644 --- a/apps/docs/src/web/api-integration.md +++ b/apps/docs/src/web/api-integration.md @@ -0,0 +1,271 @@ +# API Integration + +The web app communicates with the backend through a typed HTTP client built on [ky](https://github.com/sindresorhus/ky), with [TanStack Query](https://tanstack.com/query) handling caching, invalidation, and async state. Types are generated directly from the backend's OpenAPI spec. + +--- + +## HTTP Client + +**File:** `src/lib/ky.ts` + +```ts +import config from "@/config"; +import ky from "ky"; + +export const api = ky.create({ + prefixUrl: config.BASE_API_URL, + credentials: "include", +}); +``` + +`api` is a preconfigured ky instance used throughout every feature. Two things to note: + +- **`prefixUrl`** is set to `config.BASE_API_URL`, which resolves from the `VITE_BASE_API_URL` environment variable (see `src/config/env.ts`). In development this is read from `import.meta.env`; in production it is injected at runtime via `window.ENV`. +- **`credentials: "include"`** ensures the browser sends the `sh_session_id` cookie on every request, which is how the API authenticates the user. + +All feature-level API calls import `api` from `@/lib/ky` and call methods like `api.get(...)`, `api.post(...)`, `api.patch(...)`, and `api.delete(...)`. + +--- + +## OpenAPI TypeScript Types + +### Where they live + +``` +src/lib/openapi/ + schema.d.ts ← auto-generated, do not edit + types.ts ← hand-maintained convenience re-exports + zodSchemas.ts ← Zod schemas for a subset of schema enums +``` + +`schema.d.ts` is generated from the backend's OpenAPI spec (`apps/api/docs/swagger.yaml`) by [openapi-typescript](https://openapi-ts.dev/). It exports two root interfaces: + +- **`paths`** — every API route, its parameters, request body, and response shapes. +- **`components`** — shared schema objects (models, error envelopes, etc.). + +### How they're used + +Feature modules import directly from `schema.d.ts` to derive precise request/response types without any manual duplication: + +```ts +// src/features/Event/api/getEvent.ts +import type { operations } from "@/lib/openapi/schema"; + +type Event = + operations["get-single-event"]["responses"]["201"]["content"]["application/json"]; + +export async function getEventById(eventId: string): Promise { + return api.get(`events/${eventId}`).json(); +} +``` + +```ts +// src/features/Team/hooks/useMyTeam.ts +import type { paths } from "@/lib/openapi/schema"; + +type TeamWithMembers = + paths["/events/{eventId}/teams/me"]["get"]["responses"]["200"]["content"]["application/json"]; +``` + +```ts +// src/features/EventAdmin/hooks/useStaffActions.ts +import type { components } from "@/lib/openapi/schema"; + +type AddRoleFields = { + assignments: components["schemas"]["handlers.AssignRoleFields"][]; +}; +``` + +`src/lib/openapi/types.ts` re-exports commonly used types under shorter aliases: + +```ts +export type ErrorResponse = components["schemas"]["response.ErrorResponse"]; +export type UserContext = components["schemas"]["middleware.UserContext"]; +export type PlatformRole = components["schemas"]["sqlc.AuthUserRole"]; +export type Event = components["schemas"]["sqlc.Event"]; +export type User = components["schemas"]["sqlc.AuthUser"]; +``` + +### Regenerating types + +Run this command from `apps/web` whenever the API spec changes: + +```bash +pnpm generate:openapi +``` + +This executes: + +``` +openapi-typescript ../api/docs/swagger.yaml -o ./src/lib/openapi/schema.d.ts +``` + +The output file is committed to the repository. After regenerating, update `types.ts` and `zodSchemas.ts` if any referenced schemas changed. + +--- + +## TanStack Query + +**Setup:** `src/integrations/tanstack-query/root-provider.tsx` and `src/lib/query.ts` + +```ts +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, +}); +``` + +The `QueryClientProvider` wraps the entire application. `experimental_prefetchInRender` allows data fetching to begin during render for routes that use `useSuspenseQuery`. + +### Query key pattern + +Query keys are exported as named constants or factory functions alongside their hooks so callers can target them for invalidation: + +```ts +// src/features/Event/hooks/useEvent.ts +export function getEventQueryKey(eventId: string) { + return ["event", eventId] as const; +} + +// src/features/Application/hooks/useApplication.ts +export function getApplicationQueryKey(eventId: string, userId: string) { + return ["events", eventId, "application", userId] as const; +} + +// src/lib/auth/hooks/useUser.ts +export const queryKey = ["auth", "me"] as const; +``` + +### Query example + +```ts +// src/features/Team/hooks/useMyTeam.ts +export function useMyTeam(eventId: string) { + return useQuery({ + queryKey: ["myTeam", eventId], + queryFn: async () => { + try { + return await api.get(`events/${eventId}/teams/me`).json(); + } catch (err) { + if (err instanceof HTTPError && err.response.status === 404) { + return null; // user has no team — not an error + } + throw err; + } + }, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} +``` + +### Mutation example + +Mutations invalidate or directly update the cache in `onSuccess`. When the response is sufficient to update local state immediately, `setQueryData` avoids an extra refetch: + +```ts +// src/features/Team/hooks/useTeamActions.ts +export function useTeamActions(eventId: string) { + const queryClient = useQueryClient(); + + const create = useMutation({ + mutationFn: (data: NewTeam) => + api.post(`events/${eventId}/teams`, { json: data }).json(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["myTeam", eventId] }); + }, + }); + + const leave = useMutation({ + mutationFn: (teamId: string) => api.delete(`teams/${teamId}/members/me`), + onSuccess: () => { + queryClient.setQueryData(["myTeam", eventId], null); + }, + }); + + return { create, leave }; +} +``` + +When the server response contains the full updated resource, `setQueryData` with the returned value is preferred: + +```ts +// src/features/Event/hooks/useUpdateEvent.ts +return useMutation>({ + mutationFn: (data) => updateEventById(eventId, data), + onSuccess: (updatedEvent) => { + queryClient.setQueryData(["event", eventId], () => updatedEvent); + }, +}); +``` + +--- + +## Error Handling + +ky throws an `HTTPError` for any non-2xx response. Errors are handled at two levels: + +**1. Inside the API function** — for status codes that require specific handling before the error propagates: + +```ts +// src/features/Team/hooks/useJoinRequestActions.ts +import { HTTPError } from "ky"; +import type { ErrorResponse } from "@/lib/openapi/types"; + +async function acceptJoinRequest(requestId: string, teamId: string) { + try { + await api.post(`teams/join/${requestId}/accept`); + } catch (err) { + if (err instanceof HTTPError) { + const errorBody = await err.response.json(); + toast.error(errorBody.message || "An error occurred."); + } else { + toast.error("An error occurred."); + } + throw err; // re-throw so TanStack Query marks the mutation as failed + } +} +``` + +The `ErrorResponse` type (`{ error: string; message: string }`) matches the backend's standard error envelope at `components["schemas"]["response.ErrorResponse"]`. + +**2. Expected non-error 4xx responses** — a 404 that represents a valid empty state (e.g., user has no team) is caught and converted to `null` instead of being thrown, so the query does not enter an error state. + +Toast notifications are rendered via `react-toastify`. A custom `showToast` helper in `src/lib/toast/toast.tsx` supports structured title/message toasts; direct `toast.error()` calls are used in mutation error handlers for brevity. + +--- + +## Session Cookie Flow + +Authentication is cookie-based. The backend sets an `sh_session_id` HttpOnly cookie after a successful OAuth login. Because `credentials: "include"` is set on both the `api` ky instance and every bare `fetch` call in the auth service, the browser attaches this cookie automatically to every API request. + +**Login flow:** + +1. The user initiates sign-in. `auth.oauth.signIn("discord")` (`src/lib/auth/services/oauth.ts`) stores a CSRF nonce in an `sh_auth_nonce` cookie and redirects the browser to Discord's OAuth authorization URL. +2. Discord redirects to `VITE_BASE_API_URL/auth/callback` with `code` and `state` params. The API validates the nonce, exchanges the code for a Discord token, creates or retrieves the user account, and sets `sh_session_id` in the `Set-Cookie` response header. +3. The API redirects the browser back to the app. The session cookie is now present on all subsequent requests. + +**Session verification:** + +On mount, `auth.useUser()` (backed by `_useUser` in `src/lib/auth/hooks/useUser.ts`) issues `GET /auth/me` with `credentials: "include"`. A 200 response means the session is valid; a 401 means the user is unauthenticated. The result is cached for 10 minutes. + +```ts +// src/lib/auth/hooks/useUser.ts +export const queryKey = ["auth", "me"] as const; + +export function _useUser() { + return useQuery({ + queryKey, + queryFn: async () => await _getUser(), + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 10, // 10 minutes + retry: false, + }); +} +``` + +**Logout:** + +`auth.logOut()` sends `POST /auth/logout` with `credentials: "include"`. The API clears the session server-side. The `afterLogout` hook (configured in `src/lib/authClient.ts`) then calls `queryClient.invalidateQueries({ queryKey: ["auth", "me"] })`, which causes `useUser` to re-fetch and return an unauthenticated state, triggering a redirect to the login page. diff --git a/apps/docs/src/web/architecture.md b/apps/docs/src/web/architecture.md index e69de29b..5c93a298 100644 --- a/apps/docs/src/web/architecture.md +++ b/apps/docs/src/web/architecture.md @@ -0,0 +1,237 @@ +# Architecture & State Management + +## Directory Layout + +``` +src/ +├── components/ # Shared, reusable UI components +├── config/ # Runtime and environment configuration +├── features/ # Feature modules (see below) +├── forms/ # Shared form definitions and field presets +├── integrations/ # Third-party library bootstrapping (e.g. TanStack Query) +├── lib/ # Core internal libraries (auth, HTTP client, OpenAPI types) +├── routes/ # TanStack Router file-based route tree +├── utils/ # Pure utility functions (cn, date formatting, etc.) +├── main.tsx # Application entry point +└── routeTree.gen.ts # Auto-generated route tree (do not edit manually) +``` + +| Directory | Purpose | +|---|---| +| `components/` | Application-wide presentational components: `AppShell`, `ThemeProvider`, form primitives, `ui/` (React Aria-based design system components), icon wrappers, and loading states | +| `config/` | Zod-validated environment schema. Supports both Vite build-time env vars (`import.meta.env`) and a runtime `window.ENV` object for Docker deployments | +| `features/` | Self-contained feature modules — each owns its components, hooks, API calls, schemas, and utilities | +| `forms/` | Shared form field definitions and validation presets reused across features | +| `integrations/` | Library-specific provider bootstrapping isolated from app code. Currently contains the TanStack Query `QueryClient` factory and `Provider` wrapper | +| `lib/` | Internal libraries: `auth/` (custom OAuth2 client), `ky.ts` (HTTP client instance), `openapi/` (generated types + Zod schemas), `qr-intents/`, and `toast/` | +| `routes/` | File-based routing via TanStack Router. Includes `__root.tsx`, public pages, and the `_protected/` subtree which enforces authentication | +| `utils/` | Stateless helpers: `cn.ts` (class merging), `date.ts`, `object.ts`, `formHelper.ts` | + +--- + +## State Management + +The app uses **TanStack Query** exclusively for server state. There is no global Redux or Zustand store — component-local `useState`/`useReducer` handles ephemeral UI state, and TanStack Query owns all remote data. + +### QueryClient setup + +The `QueryClient` is instantiated inside `src/integrations/tanstack-query/root-provider.tsx` and injected into both the React tree and the TanStack Router context: + +```ts title="src/integrations/tanstack-query/root-provider.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export function getContext() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + experimental_prefetchInRender: true, // React 19 Suspense-compatible prefetching + }, + }, + }); + + return { queryClient }; +} + +export function Provider({ children, queryClient }) { + return ( + {children} + ); +} +``` + +A second `queryClient` singleton (`src/lib/query.ts`) is used by non-React code — specifically by the auth client to `invalidateQueries` after logout: + +```ts title="src/lib/authClient.ts" +export const auth = Auth({ + providers: [Discord], + redirectUri: authConfig.OAUTH_REDIRECT_URL, + hooks: { + afterLogout: async () => { + await queryClient.invalidateQueries({ queryKey: useUserQueryKey }); + }, + }, +}); +``` + +### Query conventions + +Feature hooks follow a consistent pattern: an `async` fetch function calls the `api` client, and a `useQuery` or `useMutation` wrapper is exported alongside an explicit `queryKey` factory: + +```ts title="src/features/Event/hooks/useEvent.ts" +export function getEventQueryKey(eventId: string) { + return ["event", eventId] as const; +} + +export function useEvent(eventId: string) { + return useQuery({ + queryKey: getEventQueryKey(eventId), + queryFn: () => fetchEvent(eventId), + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} +``` + +Mutations use `useQueryClient()` directly to update or invalidate related queries on success: + +```ts title="src/features/Event/hooks/useUpdateEvent.ts" +export const useUpdateEvent = (eventId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => updateEventById(eventId, data), + onSuccess: (updatedEvent) => { + queryClient.setQueryData(["event", eventId], () => updatedEvent); + }, + }); +}; +``` + +Cache invalidation is preferred over optimistic updates for write-heavy operations (e.g. creating redeemables, redeeming items). + +--- + +## Component Model + +### `features/` — feature-based colocation + +Each feature directory owns everything it needs: + +``` +features/Event/ +├── api/ # Raw fetch functions (no React, no hooks) +│ ├── getEvent.ts +│ └── updateEvent.ts +├── components/ # Feature-specific React components +│ ├── EventCard.tsx +│ └── EventSettingsForm.tsx +├── hooks/ # TanStack Query wrappers and local state hooks +│ ├── useEvent.ts +│ └── useUpdateEvent.ts +├── schemas/ # Zod schemas for the feature's data types +│ └── event.ts +└── utils/ # Pure helpers scoped to this feature + └── mapper.ts +``` + +Not all features need every subdirectory — small features (e.g. `Dashboard/`) may consist of just `components/`. + +### `components/` — shared infrastructure + +`components/` is for UI that is genuinely feature-agnostic: + +| Directory / File | Contents | +|---|---| +| `AppShell/` | Sidebar + topbar layout shell, mobile slideout nav, `AppShellContext` | +| `ui/` | Design system primitives built on React Aria Components: `Button`, `Modal`, `ComboBox`, `DatePicker`, `Badge`, etc. | +| `Form/` | Shared form layout wrappers | +| `ThemeProvider.tsx` | Dark/light/system theme context using `localStorage` | +| `Loading.tsx`, `PageLoading.tsx` | Suspense and full-page loading states | +| `ECharts.tsx` | ECharts wrapper component | + +Features consume from `components/` but never import across feature boundaries. + +--- + +## Data Flow + +``` +User action (click / form submit) + │ + ▼ +Feature hook (useQuery / useMutation) + │ + ▼ +api/ fetch function ──► ky HTTP client (src/lib/ky.ts) + │ │ + │ ▼ + │ REST API (prefixUrl: BASE_API_URL, credentials: "include") + │ │ + ▼ ▼ +TanStack Query cache ◄── JSON response / error + │ + ▼ +React component re-renders with updated data +``` + +### HTTP client + +All API calls go through a single `ky` instance configured in `src/lib/ky.ts`: + +```ts title="src/lib/ky.ts" +import ky from "ky"; +import config from "@/config"; + +export const api = ky.create({ + prefixUrl: config.BASE_API_URL, + credentials: "include", // session cookie sent on every request +}); +``` + +Feature `api/` functions call `api.get(...)`, `api.post(...)`, etc. and return typed JSON. They are plain `async` functions with no React dependency, making them independently testable. + +### OpenAPI types + +`src/lib/openapi/schema.d.ts` is generated from the API's Swagger spec via `openapi-typescript`. Feature code references `operations` and `components` from this file directly for request/response types, keeping API contracts enforced at compile time: + +```ts title="src/features/Event/api/getEvent.ts" +import type { operations } from "@/lib/openapi/schema"; + +type Event = + operations["get-single-event"]["responses"]["201"]["content"]["application/json"]; + +export async function getEventById(eventId: string): Promise { + return await api.get(`events/${eventId}`).json(); +} +``` + +--- + +## Global Providers (`main.tsx`) + +The provider stack, outermost to innermost: + +```tsx title="src/main.tsx" + + // dark/light/system CSS class on + // QueryClientProvider + // resolves auth, then mounts RouterProvider + // react-toastify portal + + + +``` + +`InnerApp` calls `auth.useUser()` (a TanStack Query hook with a 10-minute stale time and `retry: false`) and passes the resulting query object into the router context. Every route in the `_protected/` subtree awaits `context.userQuery.promise` in `beforeLoad` and redirects unauthenticated users to `/`. + +The `QueryClient` and `userQuery` are both part of the TanStack Router context (`RouterContext`), making them available to route loaders and `beforeLoad` guards without prop drilling. + +### Auth client + +`src/lib/auth/` is a small bespoke OAuth2 library — not a third-party package. It exposes: + +| Export | Description | +|---|---| +| `auth.useUser()` | TanStack Query hook; fetches `/auth/me`, returns `{ user, error }` | +| `auth.oauth.signIn(provider, redirect?)` | Initiates Discord OAuth2 redirect with a CSRF nonce cookie | +| `auth.logOut()` | POSTs to `/auth/logout`, then invalidates the `["auth", "me"]` query | +| `auth.getUser()` | Non-hook version of the `getUser` fetch for use outside React | diff --git a/apps/docs/src/web/index.md b/apps/docs/src/web/index.md index e69de29b..6fcff428 100644 --- a/apps/docs/src/web/index.md +++ b/apps/docs/src/web/index.md @@ -0,0 +1,62 @@ +# Web App Overview + +The SwampHacks web app is the primary frontend for the platform. It is a single-page application that serves participants, event organizers, and platform administrators through a unified interface. + +## Stack + +| Component | Technology | +|---|---| +| Framework | [React 19](https://react.dev) + [Vite 6](https://vitejs.dev) | +| Language | TypeScript 5.8 | +| Package manager | npm | +| Routing | [TanStack Router](https://tanstack.com/router) | +| Data fetching | [TanStack Query](https://tanstack.com/query) | +| Forms | [TanStack Form](https://tanstack.com/form) | +| Tables | [TanStack Table](https://tanstack.com/table) | +| Accessible UI | [React Aria](https://react-spectrum.adobe.com/react-aria/) + [React Aria Components](https://react-spectrum.adobe.com/react-aria/react-aria-components.html) | +| Styling | [TailwindCSS v4](https://tailwindcss.com) | +| HTTP client | [Axios](https://axios-http.com) | +| Charts | [ECharts](https://echarts.apache.org) | +| Unit testing | [Vitest](https://vitest.dev) | +| E2E testing | [Playwright](https://playwright.dev) | +| Component explorer | [Storybook 8](https://storybook.js.org) | +| Schema validation | [Zod](https://zod.dev) | + +## Feature Areas + +The application is organized into feature modules under `src/features/`: + +| Feature | Description | +|---|---| +| `Auth` | Discord OAuth2 login flow and session handling | +| `Onboarding` | New user profile setup after first login | +| `Dashboard` | Participant home screen with event status and quick actions | +| `Event` | Event browsing, detail views, and lifecycle state | +| `EventOverview` | Summarized event information for participants | +| `Application` | Hackathon application submission and status tracking | +| `ApplicationReview` | Reviewer interface for scoring and triaging applications | +| `FormBuilder` | Dynamic form schema builder for custom application questions | +| `CheckIn` | QR code and RFID-based attendee check-in | +| `Redeemables` | Prize and redeemable item tracking and redemption | +| `Team` | Team creation, join requests, and membership management | +| `Settings` | User account and preference settings | +| `Users` | User lookup and management utilities | +| `EventAdmin` | Organizer-facing event management controls | +| `PlatformAdmin` | Platform-wide administration (event manager, global settings) | + +## Key Scripts + +| Script | Command | Description | +|---|---|---| +| `dev` | `vite --host 0.0.0.0` | Start the development server | +| `build` | `vite build && tsc -b` | Production build with type checking | +| `test` | `vitest run` | Run unit tests | +| `storybook` | `storybook dev -p 6006` | Launch the Storybook component explorer | +| `generate:openapi` | `openapi-typescript ../api/docs/swagger.yaml -o ./src/lib/openapi/schema.d.ts` | Regenerate TypeScript types from the API OpenAPI spec | + +## Ports + +| Service | Port | +|---|---| +| Development server | `5173` | +| Storybook | `6006` | diff --git a/apps/docs/src/web/installation.md b/apps/docs/src/web/installation.md index e69de29b..abd21738 100644 --- a/apps/docs/src/web/installation.md +++ b/apps/docs/src/web/installation.md @@ -0,0 +1,54 @@ +# Installation & Setup + +The web app is a React + Vite SPA. It can run via Docker (part of the full stack) or directly on the host with Node.js. + +## Prerequisites + +- Node.js 22.16 via nvm (see [Getting Started](../getting-started.md)) +- pnpm: `npm install -g pnpm` + +## Environment + +Copy the example file: + +```bash +cp apps/web/.env.example apps/web/.env +``` + +| Variable | Default | Description | +|---|---|---| +| `VITE_BASE_API_URL` | `https://api.swamphacks.com` | API base URL. Use `http://localhost:8080` for local development | +| `VITE_DISCORD_OAUTH_CLIENT_ID` | — | Discord OAuth application client ID (must match the API's) | +| `VITE_ALLOWED_HOSTS` | `[""]` | JSON array of allowed host origins | + +For local development, set `VITE_BASE_API_URL=http://localhost:8080`. + +## Running locally + +```bash +cd apps/web +nvm use # picks up .nvmrc (Node 22.16) +pnpm install +pnpm dev +``` + +The app is available at **http://localhost:5173**. + +## Running via Docker + +```bash +make local # full stack including web +# or +docker compose up web +``` + +## Generating API types + +The web app uses auto-generated TypeScript types from the API's OpenAPI spec: + +```bash +cd apps/web +pnpm generate:openapi +``` + +Run this whenever the API schema changes. The output is written to `src/lib/openapi/schema.d.ts`. diff --git a/apps/docs/src/web/routing-auth.md b/apps/docs/src/web/routing-auth.md index e69de29b..74a7e766 100644 --- a/apps/docs/src/web/routing-auth.md +++ b/apps/docs/src/web/routing-auth.md @@ -0,0 +1,281 @@ +# Routing & Auth + +## Routing Setup + +The web app uses **TanStack Router** with **file-based routing**. Route files live under `src/routes/` and the router automatically generates a typed route tree at `src/routeTree.gen.ts` (committed, regenerated on build). + +The router is instantiated in `src/main.tsx` with two pieces of context injected at startup — the TanStack Query client and the user query — making both available in every `beforeLoad` and `loader` function across the tree: + +```ts +const router = createRouter({ + routeTree, + context: { + ...TanStackQueryProviderContext, // { queryClient } + userQuery: undefined!, // populated by InnerApp below + }, +}); + +function InnerApp() { + const userQuery = auth.useUser(); + return ; +} +``` + +### Route Tree Overview + +``` +/ ← login page (src/routes/index.tsx) +/privacy ← public +/terms ← public + +/_protected ← auth guard layout + /settings + /_user ← app-shell layout (navbar, logo) + /portal + /community + /resources/programming + /resources/sponsors + /admin ← superuser-only layout + /overview + /events-management + /users-management + /logs + /settings + /events/$eventId + / ← under construction + /application + /summary + /rejected + /feedback/declined + /waitlist/info + /dashboard ← event-role-aware layout + / ← role-based overview + /my-team + /teams-explorer + /_admin/… ← admin-only sub-routes + /_staff/… ← staff + admin sub-routes + /_applicant/… ← applicant + admin sub-routes + /_attendee/… ← attendee + admin sub-routes +``` + +Pathless layout segments (prefixed with `_`) group routes under a shared layout or guard without contributing a URL segment. For example, `/_protected` and `/_user` are both pathless — they exist only to run `beforeLoad` checks and render a wrapping component. + +--- + +## Protected Routes + +A single pathless layout route at `src/routes/_protected/layout.tsx` gates the entire authenticated section of the app. Its `beforeLoad` hook runs before any child route is matched: + +```ts +export const Route = createFileRoute("/_protected")({ + beforeLoad: async ({ context, location }) => { + const { user, error } = await context.userQuery.promise; + + if (!user && !error) { + throw redirect({ + to: "/", + search: { redirect: location.pathname }, + }); + } + + if (error) { + throw redirect({ to: "/" }); + } + + return { user }; + }, + pendingMs: 1000, + pendingComponent: () => PageLoading(), +}); +``` + +- If the user fetch returns neither a user nor an error (unauthenticated), the visitor is sent to `/` with a `?redirect=` query param preserving the intended destination. +- If the fetch itself errors (network or server fault), the visitor is sent to `/` with no redirect param. +- On success, `user` is forwarded into the route context for all child routes. + +The login page (`/`) has a symmetric check — if a user is already present when the root route loads, they are immediately redirected to `/portal`: + +```ts +export const Route = createFileRoute("/")({ + validateSearch: z.object({ + redirect: z.string().optional().catch(""), + }), + beforeLoad: async ({ context }) => { + const { user } = await context.userQuery.promise; + if (user) { + throw redirect({ to: "/portal" }); + } + }, +}); +``` + +### Admin Guard + +`src/routes/_protected/admin/layout.tsx` adds a second layer of enforcement on top of `/_protected`. It reads `user` from the context already resolved by the parent layout, then checks for `role === "superuser"`: + +```ts +export const Route = createFileRoute("/_protected/admin")({ + beforeLoad: async ({ context, location }) => { + const { user } = context; + + if (!user) { + throw redirect({ to: "/", search: { redirect: location.pathname } }); + } + + if (user.role !== "superuser") { + throw redirect({ to: "/portal" }); + } + + if (location.pathname === "/admin") { + throw redirect({ to: "/admin/overview" }); + } + }, +}); +``` + +Non-superusers are silently redirected to `/portal`. + +### Event-Role Guards + +Inside the event dashboard, four pathless sub-layouts gate access by event role. The parent layout (`/_protected/events/$eventId/dashboard`) resolves the user's event role via `getUserEventRole()` and places it in context as `eventRole`. Each sub-layout checks this value and calls `notFound()` if the user's role is insufficient: + +| Layout file | Allowed roles | +|---|---| +| `_admin/layout.tsx` | `admin` | +| `_staff/layout.tsx` | `admin`, `staff` | +| `_applicant/layout.tsx` | `admin`, `applicant` | +| `_attendee/layout.tsx` | `admin`, `attendee` | + +```ts +// _staff/layout.tsx — example +beforeLoad: async ({ context }) => { + if (!context.eventRole || !["admin", "staff"].includes(context.eventRole)) { + return notFound(); + } + return {}; +}, +``` + +The dashboard index route (`/dashboard/`) also redirects applicants directly to their application status page: + +```ts +beforeLoad: ({ context, params }) => { + if (context.eventRole === "applicant") { + throw redirect({ + to: `/events/$eventId/dashboard/application-status`, + params: { eventId: params.eventId }, + }); + } +}, +``` + +--- + +## Auth Flow + +Authentication uses **Discord OAuth2** (authorization code flow). The entire auth implementation lives in `src/lib/auth/` and is exposed through a single configured client at `src/lib/authClient.ts`. + +### Initiating Login + +Calling `auth.oauth.signIn("discord")` triggers `_oauthSignIn` in `src/lib/auth/services/oauth.ts`: + +1. A random UUID nonce is generated with `crypto.randomUUID()`. +2. The nonce is persisted in a `sh_auth_nonce` cookie (`sameSite: lax`; `secure` in production; domain scoped to `localhost` in dev or `.swamphacks.com` in production). +3. An OAuth state object `{ nonce, provider, redirect? }` is base64-encoded (`btoa(JSON.stringify(state))`) and passed as the `state` query parameter. +4. The browser is navigated to Discord's authorization endpoint with these parameters: + +``` +https://discord.com/oauth2/authorize + ?response_type=code + &scope=identify%20email + &client_id= + &redirect_uri=/auth/callback + &state= +``` + +### Callback Handling + +The redirect URI is `/auth/callback` — this is an **API endpoint**, not a frontend route. The API handles the code exchange, validates the nonce, creates or updates the user record, and issues an `sh_session_id` session cookie before redirecting the browser back to the frontend. See the [API auth docs](../api/auth.md) for the server-side flow. + +### Session State + +After login, all auth state is maintained by the `sh_session_id` HTTP-only cookie sent automatically by the browser. The frontend has no direct access to the session token. + +User data is fetched by `_useUser` (`src/lib/auth/hooks/useUser.ts`) using TanStack Query: + +```ts +export const queryKey = ["auth", "me"] as const; + +export function _useUser() { + return useQuery({ + queryKey, + queryFn: async () => await _getUser(), + refetchOnWindowFocus: false, + refetchOnMount: true, + staleTime: 1000 * 60 * 10, // 10 minutes + retry: false, + }); +} +``` + +`_getUser` calls `GET /auth/me` with `credentials: "include"`. A `401` response is treated as "not logged in" and returns `{ user: null, error: null }` — this is what the `/_protected` guard reads when deciding to redirect. + +The query result is passed into the router as the `userQuery` context value on every render, so all `beforeLoad` hooks can `await context.userQuery.promise` for the resolved value without triggering a separate fetch. + +### User Context Shape + +The response from `/auth/me` is validated against this Zod schema (`src/lib/auth/types/user.ts`): + +| Field | Type | Description | +|---|---|---| +| `userId` | `string` (UUID) | Unique user identifier | +| `email` | `string` | Primary email from Discord | +| `preferredEmail` | `string \| null` | User-set preferred contact email | +| `name` | `string` | Display name | +| `onboarded` | `boolean` | Whether onboarding has been completed | +| `image` | `string \| null` | Profile image URL | +| `role` | `"user" \| "superuser"` | Platform role | +| `emailConsent` | `boolean` | Whether the user has opted into emails | + +### Logout + +`auth.logOut()` (`src/lib/auth/services/user.ts`) sends `POST /auth/logout` with `credentials: "include"`. On success, the `afterLogout` hook invalidates the `["auth", "me"]` query in TanStack Query. The settings page then forces a full document reload to reset all in-memory router state: + +```ts +await auth.logOut(); +await router.navigate({ to: "/", replace: true, reloadDocument: true }); +``` + +--- + +## Onboarding Redirect + +There is no hard redirect for un-onboarded users. Instead, the `/portal` route checks the `onboarded` field from user context and the presence of a `welcome-modal-skipped` cookie to decide whether to show an onboarding modal on load: + +```ts +// src/routes/_protected/_user/portal.tsx +beforeLoad: (context) => { + const { user } = context.context; + const hasSkippedCookie = Cookies.get("welcome-modal-skipped") === "true"; + const showOnboardingModal = !hasSkippedCookie && !!user && !user.onboarded; + return { showOnboardingModal }; +}, +``` + +The `` component is then rendered conditionally based on this context value. Users who dismiss without completing onboarding have the `welcome-modal-skipped` cookie set, suppressing the modal on future visits. + +--- + +## Layout Routes & Nesting + +The app uses two distinct patterns for nested layouts: + +**Auth/guard layouts** — pathless segments that run `beforeLoad` and render `` without adding UI chrome. Examples: `/_protected`, `/_protected/events/$eventId/dashboard/_staff`. + +**Shell layouts** — pathless segments that also render the application shell (navbar, header, sidebar) around ``. Examples: + +- `/_protected/_user` — renders `` with the main navigation (Events Portal, Resources, Community). +- `/_protected/admin` — renders `` with the admin sidebar (Overview, Events Management, Users Management, Logs, Settings). +- `/_protected/events/$eventId/dashboard` — renders a role-adaptive shell: `StaffAppShell` for `admin`/`staff`, `AttendeeAppShell` for `attendee`, `ApplicantAppShell` for `applicant`. + +The `/_protected/settings` route sits directly under `/_protected` with no shell layout — it renders its own full-page centered layout. diff --git a/apps/docs/src/web/styling.md b/apps/docs/src/web/styling.md index e69de29b..52da888c 100644 --- a/apps/docs/src/web/styling.md +++ b/apps/docs/src/web/styling.md @@ -0,0 +1,312 @@ +# Styling & UI + +The web app uses TailwindCSS v4 for utility-class styling, a CSS custom property-based theme system for light/dark mode, React Aria Components as the accessible component primitive layer, and Storybook for component development. + +--- + +## TailwindCSS v4 + +Tailwind is integrated via the official Vite plugin — there is no `tailwind.config.js`. Configuration lives entirely inside CSS files using the v4 `@import` / `@theme` API. + +**`vite.config.ts`** + +```ts +import tailwindcss from "@tailwindcss/vite"; + +plugins: [tailwindcss()] +``` + +**`src/index.css`** + +```css +@import "tailwindcss"; +@plugin "tailwindcss-react-aria-components"; +@plugin "tailwindcss-animate"; + +@import "./theme.css"; + +@tailwind utilities; + +@custom-variant dark (&:where(.dark, .dark *)); +``` + +Key points: + +- `tailwindcss-react-aria-components` adds state variants (`pressed:`, `selected:`, `invalid:`, etc.) that map directly to React Aria's render props — used extensively in component styling. +- `tailwindcss-animate` provides animation utilities. +- The dark mode variant is **class-based**: `@custom-variant dark (&:where(.dark, .dark *))`. The `.dark` class is toggled on `` by `ThemeProvider`. +- No separate Tailwind config file exists; all custom tokens are declared inside `@theme inline { ... }` in `theme.css`. + +**Class merging** uses `clsx` + `tailwind-merge` via a shared utility: + +```ts +// src/utils/cn.ts +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +**`tailwind-variants`** (`tv()`) is used in place of raw class strings for components that have multiple variants — it handles variant composition, compound variants, and default variants cleanly. + +--- + +## Theme System + +All design tokens are defined as CSS custom properties in `src/theme.css`. The file has three sections: + +1. `:root` — light mode values +2. `.dark` — dark mode overrides +3. `@theme inline { ... }` — registers custom properties as Tailwind utility classes + +### Core Tokens (light / dark) + +| Token | Light | Dark | +|---|---|---| +| `--background` | `oklch(99.405% 0.00011 271.152)` | `var(--color-neutral-900)` | +| `--surface` | `oklch(97.015% 0.00011 271.152)` | `#202020` | +| `--border` | `var(--color-zinc-300)` | `var(--color-zinc-600)` | +| `--accent-main` | `var(--color-blue-500)` | `var(--color-blue-400)` | + +### Typography + +| Token | Light | Dark | +|---|---|---| +| `--text-main` | `var(--color-zinc-900)` | `var(--color-zinc-200)` | +| `--text-secondary` | `var(--color-zinc-600)` | `var(--color-zinc-400)` | +| `--text-link` | `var(--color-blue-500)` | `var(--color-blue-300)` | + +The global font is **Figtree** (loaded from Google Fonts), applied to `html`, `body`, and `#root`. Components reference it via `font-figtree` (registered as `--font-figtree: "Figtree", sans-serif` in `@theme inline`). + +### Button Tokens + +Four button variants each have `default`, `hover`, `pressed`, and `disabled` states: + +``` +--button-primary / --button-primary-hover / --button-primary-pressed / --button-primary-disabled +--button-secondary / ... +--button-danger / ... +--button-success / ... +``` + +An `outline` variant uses `oklch` with alpha for a translucent blue tint. + +### Badge Tokens + +Badges express application-level status concepts directly as tokens. Each status has a `bg` and `text` pair: + +``` +--badge-bg-accepted / --badge-text-accepted → green +--badge-bg-rejected / --badge-text-rejected → red +--badge-bg-waitlisted / --badge-text-waitlisted → violet +--badge-bg-attending / --badge-text-attending → blue +--badge-bg-under-review / --badge-text-under-review → orange +--badge-bg-staff / --badge-text-staff → sky +--badge-bg-admin / --badge-text-admin → fuchsia +--badge-bg-completed / --badge-text-completed → indigo +``` + +Dark mode badge backgrounds use raw `oklch()` values (e.g. `oklch(0.3378 0.0595 20.68)` for rejected) to achieve appropriate contrast on dark surfaces. + +### Input Tokens + +``` +--input-bg / --input-bg-disbaled +--input-border / --input-border-focused / --input-border-invalid / --input-border-disabled +--input-text-error / --input-text-disabled +``` + +### Tailwind Integration + +Every custom property is re-exported through `@theme inline` so it becomes a Tailwind utility: + +```css +@theme inline { + --color-background: var(--background); + --color-surface: var(--surface); + --color-text-main: var(--text-main); + --color-button-primary: var(--button-primary); + /* ... */ +} +``` + +This means you can write `bg-background`, `text-text-main`, `bg-button-primary`, `border-input-border`, etc. as regular Tailwind classes and they respond to the `.dark` class automatically. + +--- + +## Dark Mode + +Dark mode is managed by `ThemeProvider` (`src/components/ThemeProvider.tsx`). It supports three modes — `"light"`, `"dark"`, and `"system"` — persisted to `localStorage` under the key `"ui-theme"`. + +On mount it reads the stored preference (or falls back to the system media query) and toggles the `dark` class on ``. The companion `ThemeSwitch` component renders the Light / Dark toggle buttons visible in the navbar. + +--- + +## Component Library + +### React Aria Components + +All interactive UI primitives are built on **`react-aria-components`** (v1.10). Components in `src/components/ui/` wrap React Aria primitives and apply Tailwind classes that respond to React Aria's render-prop state. + +The `tailwindcss-react-aria-components` Tailwind plugin makes this ergonomic — it exposes state as variants: + +```tsx +// src/components/ui/Button/Button.tsx +import { Button as RACButton, composeRenderProps } from "react-aria-components"; +import { tv } from "tailwind-variants"; + +export const button = tv({ + base: "inline-flex cursor-pointer items-center justify-center rounded-md font-medium focus:outline-none gap-2", + variants: { + variant: { + primary: "bg-button-primary hover:bg-button-primary-hover pressed:bg-button-primary-pressed text-white", + secondary: "bg-button-secondary hover:bg-button-secondary-hover pressed:bg-button-secondary-pressed", + danger: "bg-button-danger hover:bg-button-danger-hover pressed:bg-button-danger-pressed text-white", + icon: "border-0 p-1 hover:bg-black/[5%] pressed:bg-black/10 dark:hover:bg-white/10", + // ... + }, + isDisabled: { + true: "cursor-not-allowed bg-gray-200 dark:bg-neutral-700 text-text-main/30", + }, + size: { sm: "py-2 px-4 text-sm", md: "py-2 px-4 text-base", lg: "py-2 px-4 text-lg" }, + }, + defaultVariants: { variant: "primary", size: "md" }, +}); + +export function Button(props: ButtonProps) { + return ( + + button({ ...renderProps, variant: props.variant, className }) + )} + /> + ); +} +``` + +`composeRenderProps` merges React Aria's live state object (`isDisabled`, `isPressed`, `isFocusVisible`, etc.) directly into the `tv()` variant resolver. This means accessibility states and visual states are always in sync — no manual ARIA attribute wiring needed. + +The helper `composeTailwindRenderProps` in `src/components/ui/utils.ts` is a thin wrapper around this pattern for cases that don't need `tv()`: + +```ts +export function composeTailwindRenderProps( + className: string | ((v: T) => string) | undefined, + tw: string, +): string | ((v: T) => string) { + return composeRenderProps(className, (className) => cn(tw, className)); +} +``` + +### Available UI Components + +All components live in `src/components/ui/` and are individually exported via `index.ts` files: + +| Component | React Aria Primitive | +|---|---| +| `Button` | `Button` | +| `TextField` | `TextField`, `Input` | +| `Select` | `Select`, `ListBox`, `Popover` | +| `ComboBox` | `ComboBox` | +| `MultiSelect` | `TagGroup`, `ListBox` | +| `Checkbox` | `Checkbox` | +| `Radio` / `RadioGroup` | `Radio`, `RadioGroup` | +| `DatePicker` | `DatePicker` | +| `DateField` | `DateField` | +| `DateRangePicker` | `DateRangePicker` | +| `Calendar` | `Calendar` | +| `Dialog` / `Modal` | `Dialog`, `Modal` | +| `Popover` | `Popover` | +| `Menu` | `Menu` | +| `ListBox` | `ListBox` | +| `Slider` | `Slider` | +| `Switch` | `Switch` | +| `ProgressBar` | `ProgressBar` | +| `Badge` | `` (via `forwardRef`) | +| `Avatar` / `AvatarStack` | Custom | +| `Card` | Custom | +| `Spinner` | Custom | +| `Separator` | Custom | +| `Tag` | Custom | + +Form field primitives (`Label`, `Description`, `FieldError`, `FieldGroup`, `Input`) are shared across all form components from `src/components/ui/Field/Field.tsx`, keeping validation display and focus styling consistent. + +### Field Validation Styling + +Invalid state is driven by CSS tokens rather than hardcoded colours: + +```tsx +// border-input-border-invalid resolves to --input-border-invalid +// which is red-600 (light) or red-300 (dark) +isFocusWithin: { true: "border-input-border-focused" }, +isInvalid: { true: "border-input-border-invalid" }, +isDisabled: { true: "border-input-border-disabled" }, +``` + +### Icon System + +Icons are sourced from **Iconify** collections via `unplugin-icons`. The `@iconify-json/tabler` and `@iconify-json/ic` sets are installed. Icons are imported as virtual modules: + +```tsx +import TablerChevronDown from "~icons/tabler/chevron-down"; +import TablerSun from "~icons/tabler/sun"; +``` + +The Vite config registers the `Icons` plugin with `compiler: "jsx"` so icons render as React SVG components. + +--- + +## Storybook + +Storybook is used to develop and document UI components in isolation. + +**Run:** + +```bash +pnpm storybook +``` + +Starts on **port 6006** (`http://localhost:6006`). + +**Build static site:** + +```bash +pnpm build-storybook +``` + +### Configuration + +`.storybook/main.ts` uses the `@storybook/react-vite` framework, so it shares the same Vite config (including Tailwind and icon plugins) as the main app. + +`.storybook/preview.ts` imports `src/index.css` so all theme tokens and Tailwind utilities are available in every story: + +```ts +import "../src/index.css"; +``` + +### Story Location + +Stories are colocated with their components following the pattern: + +``` +src/components/ui/Button/Button.stories.tsx +src/components/ui/Badge/Badge.stories.tsx +src/components/AppShell/stories/NavLink.stories.tsx +``` + +The glob in `main.ts` picks up all `*.stories.@(js|jsx|mjs|ts|tsx)` files under `src/`. Stories use the `"UI/ComponentName"` title convention (e.g. `title: "UI/Button"`) and include `tags: ["autodocs"]` to generate an automatic props table. + +Stories are also used as Storybook interaction test targets via `@storybook/experimental-addon-test`. + +--- + +## Design Conventions + +- **Color naming follows semantic role, not hue.** Prefer `bg-button-primary` over `bg-blue-600`. This allows dark mode to swap values without touching component code. +- **Status colors are defined centrally.** Application statuses (accepted, rejected, waitlisted, etc.) map to dedicated badge and event-button token families. Never use ad-hoc colour classes for status indicators. +- **`color-mix(in oklab, ...)` is used for translucent variants** in event-button and outline-button tokens — this keeps tints perceptually uniform across light and dark contexts. +- **`oklch` is the preferred colour space** for bespoke values (surface, dark-mode badge backgrounds) to ensure predictable perceptual lightness. +- **Dark mode is class-based**, not `prefers-color-scheme` media query. The `ThemeProvider` resolves system preference at runtime and applies `.dark` to ``, allowing user override. +- **`tailwind-merge` is always used** when class strings are conditionally composed, preventing specificity conflicts from duplicate utilities.