This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
# HashEx — The Hashtag Exchange
|
||||
|
||||
A stock-market simulation game where the "stocks" are Mastodon hashtags and prices are driven by real-time post activity. Inspired by the Hollywood Stock Exchange.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Players start with **$2,000** in fake money and use it to buy and sell shares in hashtags. Every day each player earns **research points** which can be spent to unlock new hashtags. Prices are calculated from the number of posts per hour on Mastodon and update automatically in the background.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | Next.js 14 (App Router, TypeScript) |
|
||||
| Database | PostgreSQL via Prisma ORM |
|
||||
| Auth | NextAuth v4 (credentials / JWT sessions) |
|
||||
| Background jobs | BullMQ + Redis |
|
||||
| Styling | Tailwind CSS (dark theme) |
|
||||
| Charts | Recharts |
|
||||
| Data source | Mastodon REST API (configurable instance) |
|
||||
| Deployment | Docker / Portainer (via Gitea Actions CI) |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
hashex/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma # Full DB schema
|
||||
│ └── seed.ts # Seeds an initial admin user
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # Home: trending + recently traded tags
|
||||
│ │ ├── layout.tsx # Root layout with Navbar
|
||||
│ │ ├── providers.tsx # NextAuth SessionProvider wrapper
|
||||
│ │ ├── globals.css # Tailwind base + dark overrides
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── signin/page.tsx # Sign-in form
|
||||
│ │ │ ├── signup/page.tsx # Registration form
|
||||
│ │ │ └── reset-password/page.tsx # Password reset (token from URL)
|
||||
│ │ ├── hashtag/[tag]/
|
||||
│ │ │ ├── page.tsx # Price chart + trade/research panel
|
||||
│ │ │ ├── TradePanel.tsx # BUY/SELL controls (client component)
|
||||
│ │ │ └── ResearchPanel.tsx # Research prompt (client component)
|
||||
│ │ ├── profile/[username]/
|
||||
│ │ │ └── page.tsx # Portfolio, positions, trade history
|
||||
│ │ ├── admin/
|
||||
│ │ │ ├── layout.tsx # Admin guard + nav links
|
||||
│ │ │ ├── page.tsx # Overview stats + leaderboard
|
||||
│ │ │ ├── users/
|
||||
│ │ │ │ ├── page.tsx # Paginated user table
|
||||
│ │ │ │ └── AdminUserActions.tsx # Edit modal + reset-link generator
|
||||
│ │ │ ├── stocks/
|
||||
│ │ │ │ ├── page.tsx # Paginated hashtag table
|
||||
│ │ │ │ └── AdminStockActions.tsx # Edit modal + force-refresh button
|
||||
│ │ │ └── queue/
|
||||
│ │ │ └── page.tsx # Live BullMQ queue monitor
|
||||
│ │ └── api/
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── [...nextauth]/route.ts # NextAuth handler
|
||||
│ │ │ ├── register/route.ts # POST — create account
|
||||
│ │ │ └── reset-password/route.ts # POST — consume token, set password
|
||||
│ │ ├── research/route.ts # POST — spend point, query Mastodon
|
||||
│ │ ├── trade/route.ts # POST — BUY/SELL with validation
|
||||
│ │ ├── user/me/route.ts # GET — current user's balance
|
||||
│ │ └── admin/
|
||||
│ │ ├── users/[userId]/route.ts # PATCH — edit balance/points
|
||||
│ │ ├── users/[userId]/reset-link/route.ts # POST — generate reset URL
|
||||
│ │ ├── stocks/[hashtagId]/route.ts # PATCH — edit price/status
|
||||
│ │ └── stocks/[hashtagId]/force-update/route.ts # POST — queue job
|
||||
│ ├── components/
|
||||
│ │ ├── Navbar.tsx # Top nav with search, balance badge, admin icon
|
||||
│ │ ├── PriceChart.tsx # Recharts line chart for price history
|
||||
│ │ └── HashtagCard.tsx # Card with tag, price, % change
|
||||
│ ├── lib/
|
||||
│ │ ├── prisma.ts # Prisma client singleton
|
||||
│ │ ├── auth.ts # NextAuth authOptions
|
||||
│ │ ├── mastodon.ts # Mastodon API fetch + pagination
|
||||
│ │ ├── queue.ts # BullMQ Queue definitions
|
||||
│ │ ├── pricing.ts # Price formula, trade math, research-point tiers
|
||||
│ │ └── utils.ts # cn(), formatCurrency(), normalizeTag(), etc.
|
||||
│ ├── middleware.ts # NextAuth route protection
|
||||
│ ├── types/
|
||||
│ │ └── next-auth.d.ts # Session type augmentation
|
||||
│ └── worker/
|
||||
│ └── index.ts # BullMQ worker process (runs in separate container)
|
||||
├── .env.example # All required env vars with descriptions
|
||||
├── Dockerfile # Single image; CMD overridden for worker container
|
||||
├── prod-compose.yml # Production compose: app + worker + postgres + redis
|
||||
├── next.config.mjs
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
└── postcss.config.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started (Local)
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- PostgreSQL instance (or `docker run -e POSTGRES_PASSWORD=password -p 5432:5432 postgres:16-alpine`)
|
||||
- Redis instance (or `docker run -p 6379:6379 redis:7-alpine`)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Copy env and fill in your values
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Apply the database schema
|
||||
npm run db:push
|
||||
|
||||
# 4. Seed the admin user (see .env.example for ADMIN_USERNAME / ADMIN_PASSWORD)
|
||||
npm run db:seed
|
||||
|
||||
# 5. Start the web server
|
||||
npm run dev
|
||||
|
||||
# 6. In a second terminal, start the background worker
|
||||
npm run worker:dev
|
||||
```
|
||||
|
||||
The app will be at `http://localhost:3000`. The seeded admin credentials default to `admin` / `changeme123` — change them immediately.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All variables are documented in `.env.example`. Key ones:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | — | PostgreSQL connection string |
|
||||
| `NEXTAUTH_SECRET` | — | Random secret for session signing (`openssl rand -base64 32`) |
|
||||
| `NEXTAUTH_URL` | `http://localhost:3000` | Base URL of your deployment |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
|
||||
| `MASTODON_INSTANCE` | `https://mastodon.social` | Mastodon instance base URL |
|
||||
| `MASTODON_ACCESS_TOKEN` | _(optional)_ | Bearer token — increases API rate limits |
|
||||
| `WORKER_RATE_LIMIT_MS` | `2000` | Milliseconds to wait between Mastodon API calls |
|
||||
| `PRICE_UPDATE_INTERVAL_MINUTES` | `60` | How often to sweep all active hashtags |
|
||||
| `MAX_PAGES_PER_HASHTAG` | `5` | Max Mastodon pages fetched per tag (5 × 40 = up to 200 posts) |
|
||||
|
||||
---
|
||||
|
||||
## Pricing Formula
|
||||
|
||||
```
|
||||
price = max($0.25, round(postsPerHour × $0.25, 2))
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
| Posts/hr | Price |
|
||||
|---|---|
|
||||
| 1 | $0.25 |
|
||||
| 10 | $2.50 |
|
||||
| 100 | $25.00 |
|
||||
| 1,000 | $250.00 |
|
||||
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 |
|
||||
|
||||
**Burst handling:** when all fetched posts share a very tight timestamp window the worker paginates up to `MAX_PAGES_PER_HASHTAG` pages to get a realistic count before the span grows to > 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Research System
|
||||
|
||||
- Every player earns **1 research point per day** (awarded at 00:05 UTC by the maintenance worker).
|
||||
- Balance milestones unlock extra daily points:
|
||||
|
||||
| Balance | Daily points |
|
||||
|---|---|
|
||||
| < $10,000 | 1 |
|
||||
| $10,000+ | 2 |
|
||||
| $100,000+ | 3 |
|
||||
| $1,000,000+ | 5 |
|
||||
|
||||
- Spending a research point queries Mastodon for that hashtag:
|
||||
- **No results found** → point is spent, hashtag not added.
|
||||
- **Results found** → hashtag is created, price set, job queued for immediate update.
|
||||
- If no player holds a position in a hashtag and it returns zero results for **3 consecutive update cycles**, it is automatically **deactivated**. A player must research it again to reactivate it.
|
||||
|
||||
---
|
||||
|
||||
## Trading
|
||||
|
||||
Four trade types are supported:
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `BUY_LONG` | Buy shares betting price goes up |
|
||||
| `SELL_LONG` | Close or reduce a long position |
|
||||
| `BUY_SHORT` | Place collateral betting price goes down |
|
||||
| `SELL_SHORT` | Close a short, return collateral adjusted for price movement |
|
||||
|
||||
**Safeguards enforced server-side:**
|
||||
- Cannot spend more than current balance on a buy.
|
||||
- Cannot sell more shares than you hold in a position.
|
||||
- Trades are executed in a database transaction (balance + position + trade record atomically).
|
||||
|
||||
---
|
||||
|
||||
## Background Worker
|
||||
|
||||
The worker runs as a **separate Docker container** using the same image with a different `CMD`.
|
||||
|
||||
Three BullMQ queues:
|
||||
|
||||
| Queue | Purpose |
|
||||
|---|---|
|
||||
| `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
|
||||
| `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. |
|
||||
| `hashex-maintenance` | Runs daily at 00:05 UTC. Awards research points based on each player's balance. |
|
||||
|
||||
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
|
||||
|
||||
---
|
||||
|
||||
## Admin Dashboard (`/admin`)
|
||||
|
||||
Accessible only to users with `isAdmin = true`.
|
||||
|
||||
### Overview
|
||||
- Platform stats (users, active/inactive hashtags, trade count)
|
||||
- Top 10 players by balance
|
||||
- Recent 10 trades across the platform
|
||||
|
||||
### Users (`/admin/users`)
|
||||
- Searchable, paginated user list
|
||||
- **Edit modal** per user: adjust balance and research points
|
||||
- **Generate reset link**: creates a one-time password reset URL (2-hour expiry). No email is sent — the admin copies and shares the link with the user.
|
||||
|
||||
### Stocks (`/admin/stocks`)
|
||||
- Searchable, filterable (active/inactive), paginated hashtag list
|
||||
- **Edit modal** per hashtag: override price, toggle active/inactive
|
||||
- **Force price refresh**: adds a high-priority job to the update queue immediately
|
||||
|
||||
### Queue Monitor (`/admin/queue`)
|
||||
- Live view of all three BullMQ queues
|
||||
- Shows waiting, active, delayed, completed, and failed counts
|
||||
- Lists active, waiting, and recent failed jobs with payload and failure reason
|
||||
|
||||
---
|
||||
|
||||
## Password Reset Flow
|
||||
|
||||
Since there is no email system, password resets are handled manually by an admin:
|
||||
|
||||
1. Admin navigates to `/admin/users`, finds the user, opens the edit modal.
|
||||
2. Clicks **"Generate reset link"**.
|
||||
3. Copies the displayed URL and sends it to the user via Discord/chat/etc.
|
||||
4. User visits the URL at `/auth/reset-password?token=<token>`, sets a new password.
|
||||
5. The token is marked used and expires after 2 hours. A new link invalidates any previous unused token for that user.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
The CI pipeline is defined in `.gitea/workflows/rebuild-prod.yaml`. On push to `main` it:
|
||||
|
||||
1. Decodes the `PROD_ENV` secret (base64-encoded `.env`) and writes `.env`.
|
||||
2. Builds the Docker image and pushes to the registry.
|
||||
3. Fetches the existing Portainer stack, preserves its env vars, and redeploys with the new image.
|
||||
|
||||
The `prod-compose.yml` defines four services:
|
||||
|
||||
| Service | Notes |
|
||||
|---|---|
|
||||
| `app` | Web server on port 3000. Runs `prisma db push` before starting. |
|
||||
| `worker` | Same image, `CMD: npm run worker:prod`. Handles all background jobs. |
|
||||
| `postgres` | Postgres 16 Alpine with a named volume. Health-checked. |
|
||||
| `redis` | Redis 7 Alpine with AOF persistence. |
|
||||
|
||||
### Creating the `PROD_ENV` secret
|
||||
|
||||
```bash
|
||||
# Generate a base64-encoded copy of your production .env file:
|
||||
base64 -w 0 .env
|
||||
# Paste the result as the PROD_ENV secret in Gitea.
|
||||
```
|
||||
|
||||
### Generating `NEXTAUTH_SECRET`
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Summary
|
||||
|
||||
| Model | Key fields |
|
||||
|---|---|
|
||||
| `User` | `username`, `passwordHash`, `balance`, `researchPoints`, `isAdmin` |
|
||||
| `PasswordReset` | `token`, `userId`, `expiresAt`, `used` |
|
||||
| `Hashtag` | `tag` (lowercase), `displayTag`, `currentPrice`, `isActive`, `zeroCount`, `lastUpdated` |
|
||||
| `PriceHistory` | `hashtagId`, `price`, `postsPerHour`, `recordedAt` |
|
||||
| `Position` | `userId`, `hashtagId`, `positionType` (LONG/SHORT), `shares`, `avgBuyPrice` |
|
||||
| `Trade` | `userId`, `hashtagId`, `type`, `shares`, `price`, `total`, `profit` |
|
||||
|
||||
---
|
||||
|
||||
## Useful Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Next.js dev server (hot reload)
|
||||
npm run build # Production build
|
||||
npm run start # Start production web server
|
||||
npm run worker:dev # Worker with hot reload (tsx watch)
|
||||
npm run worker:prod # Worker for production
|
||||
npm run db:push # Apply schema changes without migrations
|
||||
npm run db:migrate # Create and apply a migration
|
||||
npm run db:seed # Seed the admin user
|
||||
npm run db:studio # Open Prisma Studio (GUI)
|
||||
```
|
||||
Reference in New Issue
Block a user