372 lines
15 KiB
Markdown
372 lines
15 KiB
Markdown
# 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
|
||
|
||
Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices:
|
||
|
||
```
|
||
price = max($0.25, round((base × pph) / (1 + k × pph), 2))
|
||
```
|
||
|
||
`k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second).
|
||
|
||
| Posts/hr | Price |
|
||
|---|---|
|
||
| 1 | ~$0.25 |
|
||
| 10 | ~$2.48 |
|
||
| 100 | ~$23.32 |
|
||
| 1,000 | ~$145 |
|
||
| 3,600 (one post/sec) | ~$250 |
|
||
| ∞ (theoretical) | ~$346 (asymptote) |
|
||
|
||
At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market.
|
||
|
||
**Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window.
|
||
|
||
---
|
||
|
||
## Research System
|
||
|
||
- Every player earns **1 research point per day** (awarded at midnight EST 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 midnight EST. 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
|
||
- **Retry failed** button per queue to requeue all failed jobs
|
||
|
||
### Hedge Funds (`/admin/funds`)
|
||
- Create a named hedge fund with an initial starting balance
|
||
- Add or remove managers (by username) per fund
|
||
- Delete a fund (removes all its positions and trade history)
|
||
|
||
---
|
||
|
||
## Hedge Funds
|
||
|
||
Multiple players can collaborate via a **Hedge Fund** — a shared pool of capital with its own balance, positions, and trade history.
|
||
|
||
### How it works
|
||
1. An admin creates a fund at `/admin/funds` (providing a name and initial balance).
|
||
2. The admin adds one or more players as **managers** of the fund.
|
||
3. A manager visits `/fund/[slug]` to see the fund's portfolio.
|
||
4. From there they can click any held position (or browse all stocks) — the link includes `?fund=[slug]`, putting the hashtag trade page in **Fund Mode**.
|
||
5. In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's.
|
||
6. The manager can return to their `/profile` at any time to trade under their own account.
|
||
|
||
### Fund page (`/fund/[slug]`)
|
||
- Public — anyone can view the fund name, balance, positions, managers, and trade history.
|
||
- Managers see a management panel with quick links to trade each held position in Fund Mode.
|
||
|
||
### Key rules
|
||
- A fund account cannot sign in directly — it is a shadow account controlled via the manager interface.
|
||
- A user can manage multiple funds simultaneously.
|
||
- Funds do not earn research points or play the daily lottery.
|
||
|
||
---
|
||
|
||
## 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:seed # Seed the admin user
|
||
npm run db:studio # Open Prisma Studio (GUI)
|
||
```
|
||
|
||
---
|
||
|
||
## Feature Roadmap
|
||
|
||
The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase.
|
||
|
||
---
|
||
|
||
### Other Ideas / Nice-to-Haves
|
||
|
||
- **Email integration**: SMTP-based password reset and optional trade confirmation emails.
|
||
- **Multi-instance support**: fallback to another instance if the primary instance is unavailable or throttles API calls.
|
||
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
|
||
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
|
||
- **Dark/light theme toggle**: currently dark-only.
|