first commit!
Build Images and Deploy / Update-PROD-Stack (push) Failing after 15s

This commit is contained in:
2026-03-18 15:44:49 -04:00
commit 3b479f8382
56 changed files with 7387 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Database
DATABASE_URL="postgresql://hashex:password@localhost:5432/hashex"
# NextAuth
NEXTAUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
NEXTAUTH_URL="http://localhost:3000"
# Redis (BullMQ)
REDIS_URL="redis://localhost:6379"
# Mastodon API - configurable instance
MASTODON_INSTANCE="https://mastodon.social"
MASTODON_ACCESS_TOKEN="your-mastodon-access-token"
# Worker tuning
# Milliseconds to wait between Mastodon API calls (default: 2000 = 2s, safe for most instances)
WORKER_RATE_LIMIT_MS=2000
# How often (minutes) to queue a full price-update sweep (default: 60)
PRICE_UPDATE_INTERVAL_MINUTES=60
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
MAX_PAGES_PER_HASHTAG=5
# Postgres (used by postgres container in prod-compose.yml)
POSTGRES_DB=hashex
POSTGRES_USER=hashex
POSTGRES_PASSWORD=changeme
+89
View File
@@ -0,0 +1,89 @@
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
name: Build Images and Deploy
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
on:
push:
# not working right now https://github.com/actions/runner/issues/2324
# paths-ignore:
# - **.yml
branches:
- main
env:
STACK_NAME: hashex
DOT_ENV: ${{ secrets.PROD_ENV }}
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
ENDPOINT_NAME: "mini" #sometimes "primary"
IMAGE_TAG: "reg.dev.nervesocket.com/hashex:latest"
jobs:
Update-PROD-Stack:
runs-on: ubuntu-latest
steps:
# if: contains(github.event.pull_request.head.ref, 'init-stack')
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push PROD Docker image
run: |
echo $DOT_ENV | base64 -d > .env
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
- name: Get the endpoint ID
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
run: |
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
echo "Got stack Endpoint ID: $ENDPOINT_ID"
- name: Fetch stack ID from Portainer
run: |
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
- name: Fetch Stack
run: |
# Get the stack details (including env vars)
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
# Extract environment variables from the stack
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
echo "Existing stack environment variables:"
cat stack_env.json
- name: Redeploy stack in Portainer
run: |
# Read stack file content
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
# Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json)
# Prepare JSON payload with environment variables
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
echo "About to push the following JSON payload:"
echo $JSON_PAYLOAD
# Update stack in Portainer (this redeploys it)
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
-H "X-API-Key: $PORTAINER_TOKEN" \
-H "Content-Type: application/json" \
--data "$JSON_PAYLOAD")
echo "Redeployed stack in Portainer. Response:"
echo $DEPLOY_RESPONSE
- name: Status check
run: |
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.next/
.env
.env.local
.env.*.local
*.log
dist/
.DS_Store
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-alpine
WORKDIR /app
# Required for Prisma on Alpine
RUN apk add --no-cache openssl
# Install all deps (including devDeps for tsx worker runtime + build tooling)
COPY package*.json ./
RUN npm ci
# Copy source
COPY . .
# Generate Prisma client and build Next.js (postinstall handles prisma generate)
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
# Default: run web server. Worker container overrides this CMD.
CMD ["npm", "start"]
+322
View File
@@ -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)
```
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
},
}
export default nextConfig
+3150
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "hashex",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"worker:dev": "tsx watch src/worker/index.ts",
"worker:prod": "tsx src/worker/index.ts",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"postinstall": "prisma generate"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
"next": "14.2.35",
"next-auth": "^4.24.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.14.1",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.17.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+111
View File
@@ -0,0 +1,111 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
balance Float @default(2000)
researchPoints Int @default(1)
isAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
positions Position[]
trades Trade[]
passwordResets PasswordReset[]
}
model PasswordReset {
id String @id @default(cuid())
token String @unique @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
@@index([token])
}
model Hashtag {
id String @id @default(cuid())
tag String @unique // lowercase, no #
displayTag String // original case as entered
currentPrice Float @default(0.25)
isActive Boolean @default(true)
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
zeroCount Int @default(0)
lastUpdated DateTime @default(now())
createdAt DateTime @default(now())
priceHistory PriceHistory[]
positions Position[]
trades Trade[]
@@index([isActive, lastUpdated])
}
model PriceHistory {
id String @id @default(cuid())
hashtagId String
hashtag Hashtag @relation(fields: [hashtagId], references: [id], onDelete: Cascade)
price Float
postsPerHour Float
recordedAt DateTime @default(now())
@@index([hashtagId, recordedAt])
}
model Position {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashtagId String
hashtag Hashtag @relation(fields: [hashtagId], references: [id])
shares Float @default(0)
positionType PositionType
avgBuyPrice Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, hashtagId, positionType])
@@index([userId])
@@index([hashtagId])
}
model Trade {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashtagId String
hashtag Hashtag @relation(fields: [hashtagId], references: [id])
type TradeType
shares Float
price Float // price per share at time of trade
total Float // cost/proceeds of the trade
profit Float @default(0) // realized P&L (for SELL trades)
createdAt DateTime @default(now())
@@index([userId])
@@index([hashtagId])
@@index([createdAt])
}
enum PositionType {
LONG
SHORT
}
enum TradeType {
BUY_LONG
SELL_LONG
BUY_SHORT
SELL_SHORT
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Seed script: creates an initial admin user.
* Usage: npm run db:seed
*
* Set ADMIN_USERNAME and ADMIN_PASSWORD env vars, or use the defaults below.
* Change the defaults before running in production.
*/
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const username = process.env.ADMIN_USERNAME ?? 'admin'
const password = process.env.ADMIN_PASSWORD ?? 'changeme123'
const existing = await prisma.user.findUnique({ where: { username } })
if (existing) {
console.log(`User "${username}" already exists — skipping seed.`)
return
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: {
username,
passwordHash,
isAdmin: true,
balance: 10000, // admins start with extra for testing
researchPoints: 10,
},
})
console.log(`Created admin user: ${user.username} (id: ${user.id})`)
console.log('Remember to change the password after first login!')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+64
View File
@@ -0,0 +1,64 @@
services:
app:
image: reg.dev.nervesocket.com/hashex:latest
# Run migrations then start the web server
command: sh -c "npx prisma db push --accept-data-loss && npm start"
ports:
- "3000:3000"
environment:
DATABASE_URL: "${DATABASE_URL}"
NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
NEXTAUTH_URL: "${NEXTAUTH_URL}"
REDIS_URL: "${REDIS_URL}"
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
worker:
image: reg.dev.nervesocket.com/hashex:latest
command: npm run worker:prod
environment:
DATABASE_URL: "${DATABASE_URL}"
REDIS_URL: "${REDIS_URL}"
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}"
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}"
MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
postgres:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: "${POSTGRES_DB:-hashex}"
POSTGRES_USER: "${POSTGRES_USER:-hashex}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-hashex}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
+37
View File
@@ -0,0 +1,37 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
redirect('/')
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-surface-border">
<span className="text-amber-400 text-xl"></span>
<h1 className="text-xl font-bold">Admin Dashboard</h1>
</div>
<nav className="flex gap-4 text-sm">
{[
{ href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/stocks', label: 'Stocks' },
{ href: '/admin/queue', label: 'Queue' },
].map((link) => (
<a
key={link.href}
href={link.href}
className="text-slate-400 hover:text-slate-100 transition-colors"
>
{link.label}
</a>
))}
</nav>
{children}
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils'
import { Users, Hash, TrendingUp, Activity } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default async function AdminOverviewPage() {
const [userCount, activeHashtags, inactiveHashtags, totalTrades, recentTrades, topUsers] =
await Promise.all([
prisma.user.count(),
prisma.hashtag.count({ where: { isActive: true } }),
prisma.hashtag.count({ where: { isActive: false } }),
prisma.trade.count(),
prisma.trade.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
include: {
user: { select: { username: true } },
hashtag: { select: { displayTag: true, tag: true } },
},
}),
prisma.user.findMany({
orderBy: { balance: 'desc' },
take: 10,
select: { id: true, username: true, balance: true, isAdmin: true },
}),
])
return (
<div className="space-y-8">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={<Users className="h-5 w-5 text-indigo-400" />} label="Total users" value={userCount.toLocaleString()} />
<StatCard icon={<Hash className="h-5 w-5 text-emerald-400" />} label="Active hashtags" value={activeHashtags.toLocaleString()} />
<StatCard icon={<Activity className="h-5 w-5 text-amber-400" />} label="Inactive hashtags" value={inactiveHashtags.toLocaleString()} />
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Total trades" value={totalTrades.toLocaleString()} />
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Top users by balance */}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="px-4 py-3 font-medium text-sm border-b border-surface-border">
Top players by balance
</h2>
<div className="divide-y divide-surface-border">
{topUsers.map((u, i) => (
<div key={u.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-600 w-5 text-xs">{i + 1}</span>
<a href={`/admin/users?q=${u.username}`} className="hover:text-indigo-300">
{u.username}
</a>
{u.isAdmin && (
<span className="text-xs bg-amber-500/20 text-amber-400 px-1.5 rounded">admin</span>
)}
</div>
<span className="font-medium">{formatCurrency(u.balance)}</span>
</div>
))}
</div>
</div>
{/* Recent trades */}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="px-4 py-3 font-medium text-sm border-b border-surface-border">
Recent trades
</h2>
<div className="divide-y divide-surface-border">
{recentTrades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span
className={`text-xs px-1.5 py-0.5 rounded ${
t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}
>
{t.type.replace('_', ' ')}
</span>
<span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500">#{t.hashtag.displayTag}</span>
</div>
<span>{formatCurrency(t.total)}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-center gap-3">
{icon}
<div>
<p className="text-xl font-bold">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue'
import { formatDistanceToNow } from 'date-fns'
export const dynamic = 'force-dynamic'
interface QueueSummary {
name: string
waiting: number
active: number
completed: number
failed: number
delayed: number
jobs: {
id: string | undefined
name: string
state: string
data: Record<string, unknown>
failReason?: string
timestamp: number
processedOn?: number | null
finishedOn?: number | null
attemptsMade: number
}[]
}
async function getQueueSummary(queue: typeof priceUpdateQueue): Promise<QueueSummary> {
const [
waitingCount,
activeCount,
completedCount,
failedCount,
delayedCount,
activeJobs,
waitingJobs,
failedJobs,
] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
queue.getJobs(['active']),
queue.getJobs(['waiting'], 0, 9),
queue.getJobs(['failed'], 0, 5),
])
const allJobs = [...activeJobs, ...waitingJobs, ...failedJobs]
return {
name: queue.name,
waiting: waitingCount,
active: activeCount,
completed: completedCount,
failed: failedCount,
delayed: delayedCount,
jobs: allJobs.map((j) => ({
id: j.id,
name: j.name,
state: activeJobs.includes(j) ? 'active' : waitingJobs.includes(j) ? 'waiting' : 'failed',
data: j.data,
failReason: j.failedReason,
timestamp: j.timestamp,
processedOn: j.processedOn,
finishedOn: j.finishedOn,
attemptsMade: j.attemptsMade,
})),
}
}
export default async function AdminQueuePage() {
const [priceSummary, maintenanceSummary, schedulerSummary] = await Promise.all([
getQueueSummary(priceUpdateQueue),
getQueueSummary(maintenanceQueue),
getQueueSummary(schedulerQueue),
])
const queues = [priceSummary, maintenanceSummary, schedulerSummary]
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Queue Monitor</h2>
<span className="text-xs text-slate-500">Auto-refreshes every 10s</span>
</div>
{queues.map((q) => (
<div key={q.name} className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Queue header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border">
<h3 className="font-medium text-sm">{q.name}</h3>
<div className="flex items-center gap-3 text-xs">
<Badge label="waiting" count={q.waiting} color="slate" />
<Badge label="active" count={q.active} color="indigo" />
<Badge label="delayed" count={q.delayed} color="amber" />
<Badge label="completed" count={q.completed} color="emerald" />
<Badge label="failed" count={q.failed} color="red" />
</div>
</div>
{/* Jobs list */}
{q.jobs.length === 0 ? (
<p className="text-slate-500 text-sm px-4 py-3">No active, waiting, or failed jobs.</p>
) : (
<div className="divide-y divide-surface-border">
{q.jobs.map((job) => (
<div key={job.id} className="px-4 py-3 text-sm">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<StateChip state={job.state} />
<span className="font-medium truncate">{job.name}</span>
{job.id && (
<span className="text-slate-600 text-xs font-mono shrink-0">#{job.id}</span>
)}
</div>
<div className="text-xs text-slate-500 shrink-0">
{formatDistanceToNow(new Date(job.timestamp), { addSuffix: true })}
{job.attemptsMade > 1 && (
<span className="ml-2 text-amber-400">{job.attemptsMade} attempts</span>
)}
</div>
</div>
{/* Job data */}
{Object.keys(job.data).length > 0 && (
<p className="text-xs text-slate-500 mt-1 font-mono">
{JSON.stringify(job.data)}
</p>
)}
{/* Failure reason */}
{job.failReason && (
<p className="text-xs text-red-400 mt-1 bg-red-400/10 rounded px-2 py-1 font-mono">
{job.failReason}
</p>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)
}
function Badge({
label,
count,
color,
}: {
label: string
count: number
color: 'slate' | 'indigo' | 'amber' | 'emerald' | 'red'
}) {
const colors = {
slate: 'bg-slate-500/20 text-slate-400',
indigo: 'bg-indigo-500/20 text-indigo-400',
amber: 'bg-amber-500/20 text-amber-400',
emerald: 'bg-emerald-500/20 text-emerald-400',
red: 'bg-red-500/20 text-red-400',
}
return (
<span className={`px-1.5 py-0.5 rounded font-medium ${colors[color]}`}>
{label}: {count}
</span>
)
}
function StateChip({ state }: { state: string }) {
const map: Record<string, string> = {
active: 'bg-indigo-500/20 text-indigo-300',
waiting: 'bg-slate-500/20 text-slate-300',
failed: 'bg-red-500/20 text-red-400',
}
return (
<span className={`text-xs px-1.5 py-0.5 rounded ${map[state] ?? 'bg-slate-500/20 text-slate-400'}`}>
{state}
</span>
)
}
+123
View File
@@ -0,0 +1,123 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface HashtagData {
id: string
tag: string
displayTag: string
currentPrice: number
isActive: boolean
zeroCount: number
}
export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [price, setPrice] = useState(String(hashtag.currentPrice))
const [isActive, setIsActive] = useState(hashtag.isActive)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSave() {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/stocks/${hashtag.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPrice: parseFloat(price), isActive }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Save failed.')
} else {
setOpen(false)
router.refresh()
}
}
async function handleForceUpdate() {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/stocks/${hashtag.id}/force-update`, { method: 'POST' })
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Failed to queue update.')
} else {
setOpen(false)
router.refresh()
}
}
return (
<>
<button
onClick={() => { setOpen(true); setError('') }}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
{open && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-sm space-y-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold text-lg">Edit #{hashtag.displayTag}</h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-slate-400 mb-1">Current price</label>
<input
type="number"
step="0.01"
min="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="isActive" className="text-sm text-slate-300">Active</label>
</div>
<button
onClick={handleForceUpdate}
disabled={loading}
className="w-full text-sm bg-surface border border-surface-border hover:border-indigo-500/50 px-4 py-2 rounded-lg transition-colors"
>
Force price refresh (queue job)
</button>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<div className="flex justify-end gap-3">
<button onClick={() => setOpen(false)} className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200">Cancel</button>
<button
onClick={handleSave}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm"
>
{loading ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
</>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { AdminStockActions } from './AdminStockActions'
interface Props {
searchParams: { q?: string; page?: string; filter?: string }
}
export const revalidate = 0
export default async function AdminStocksPage({ searchParams }: Props) {
const q = searchParams.q ?? ''
const filter = searchParams.filter ?? 'all'
const page = parseInt(searchParams.page ?? '1', 10)
const pageSize = 25
const skip = (page - 1) * pageSize
const where = {
...(q ? { tag: { contains: q.toLowerCase() } } : {}),
...(filter === 'active' ? { isActive: true } : filter === 'inactive' ? { isActive: false } : {}),
}
const [hashtags, total] = await Promise.all([
prisma.hashtag.findMany({
where,
orderBy: { lastUpdated: 'desc' },
skip,
take: pageSize,
include: {
_count: { select: { positions: true, trades: true } },
},
}),
prisma.hashtag.count({ where }),
])
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-lg font-semibold">Stocks ({total})</h2>
<form className="flex gap-2 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder="Search tag…"
className="bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 w-40"
/>
<select
name="filter"
defaultValue={filter}
className="bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none"
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button
type="submit"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-lg text-sm transition-colors"
>
Filter
</button>
</form>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-surface-border">
<tr className="text-slate-400 text-left">
<th className="px-4 py-3">Tag</th>
<th className="px-4 py-3">Price</th>
<th className="px-4 py-3 hidden md:table-cell">Status</th>
<th className="px-4 py-3 hidden md:table-cell">Investors</th>
<th className="px-4 py-3 hidden lg:table-cell">Last updated</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-border">
{hashtags.map((h) => (
<tr key={h.id} className="hover:bg-surface-hover">
<td className="px-4 py-3">
<a href={`/hashtag/${h.tag}`} className="hover:text-indigo-300">
#{h.displayTag}
</a>
</td>
<td className="px-4 py-3 font-medium">{formatCurrency(h.currentPrice)}</td>
<td className="px-4 py-3 hidden md:table-cell">
<span
className={`text-xs px-2 py-0.5 rounded ${
h.isActive
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{h.isActive ? 'active' : 'inactive'}
</span>
</td>
<td className="px-4 py-3 hidden md:table-cell">{h._count.positions}</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-400">
{formatDistanceToNow(new Date(h.lastUpdated), { addSuffix: true })}
</td>
<td className="px-4 py-3">
<AdminStockActions hashtag={h} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 text-sm">
{page > 1 && (
<a href={`?q=${q}&filter=${filter}&page=${page - 1}`} className="px-3 py-1 bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50">
Prev
</a>
)}
<span className="text-slate-400">Page {page} of {totalPages}</span>
{page < totalPages && (
<a href={`?q=${q}&filter=${filter}&page=${page + 1}`} className="px-3 py-1 bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50">
Next
</a>
)}
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
interface UserData {
id: string
username: string
balance: number
researchPoints: number
isAdmin: boolean
}
export function AdminUserActions({ user }: { user: UserData }) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [balance, setBalance] = useState(String(user.balance))
const [points, setPoints] = useState(String(user.researchPoints))
const [loading, setLoading] = useState(false)
const [resetUrl, setResetUrl] = useState<string | null>(null)
const [error, setError] = useState('')
async function handleSave() {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
balance: parseFloat(balance),
researchPoints: parseInt(points, 10),
}),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Save failed.')
} else {
setOpen(false)
router.refresh()
}
}
async function handleGenerateReset() {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/users/${user.id}/reset-link`, {
method: 'POST',
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Failed to generate link.')
} else {
setResetUrl(data.url)
}
}
return (
<>
<button
onClick={() => { setOpen(true); setResetUrl(null); setError('') }}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
{open && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div
className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-md space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-lg">Edit user: {user.username}</h3>
<div className="space-y-3">
<div>
<label className="block text-sm text-slate-400 mb-1">Balance</label>
<input
type="number"
value={balance}
onChange={(e) => setBalance(e.target.value)}
step="0.01"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Research points</label>
<input
type="number"
value={points}
onChange={(e) => setPoints(e.target.value)}
min="0"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
{/* Reset URL section */}
<div className="border-t border-surface-border pt-4">
<p className="text-sm text-slate-400 mb-2">Password reset</p>
<button
onClick={handleGenerateReset}
disabled={loading}
className="text-sm bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 px-4 py-1.5 rounded-lg transition-colors"
>
Generate reset link
</button>
{resetUrl && (
<div className="mt-2 bg-surface rounded-lg p-3 break-all">
<p className="text-xs text-slate-400 mb-1">Send this URL to the user (expires in 2 hours):</p>
<p className="text-xs text-emerald-400 font-mono">{resetUrl}</p>
<button
onClick={() => navigator.clipboard.writeText(resetUrl)}
className="text-xs text-indigo-400 hover:text-indigo-300 mt-2"
>
Copy to clipboard
</button>
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setOpen(false)}
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 rounded-lg text-sm transition-colors"
>
{loading ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
</div>
)}
</>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils'
import { AdminUserActions } from './AdminUserActions'
interface Props {
searchParams: { q?: string; page?: string }
}
export const revalidate = 0
export default async function AdminUsersPage({ searchParams }: Props) {
const q = searchParams.q ?? ''
const page = parseInt(searchParams.page ?? '1', 10)
const pageSize = 25
const skip = (page - 1) * pageSize
const where = q
? { username: { contains: q, mode: 'insensitive' as const } }
: {}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
select: {
id: true,
username: true,
balance: true,
researchPoints: true,
isAdmin: true,
createdAt: true,
_count: { select: { trades: true, positions: true } },
},
}),
prisma.user.count({ where }),
])
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">Users ({total})</h2>
<form className="flex gap-2">
<input
name="q"
defaultValue={q}
placeholder="Search username…"
className="bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 w-48"
/>
<button
type="submit"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-1.5 rounded-lg text-sm transition-colors"
>
Search
</button>
</form>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-surface-border">
<tr className="text-slate-400 text-left">
<th className="px-4 py-3">Username</th>
<th className="px-4 py-3">Balance</th>
<th className="px-4 py-3 hidden md:table-cell">Research pts</th>
<th className="px-4 py-3 hidden md:table-cell">Trades</th>
<th className="px-4 py-3 hidden lg:table-cell">Positions</th>
<th className="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-surface-hover transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
{user.username}
</a>
{user.isAdmin && (
<span className="text-xs bg-amber-500/20 text-amber-400 px-1.5 rounded">
admin
</span>
)}
</div>
</td>
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
<td className="px-4 py-3 hidden md:table-cell">{user.researchPoints}</td>
<td className="px-4 py-3 hidden md:table-cell">{user._count.trades}</td>
<td className="px-4 py-3 hidden lg:table-cell">{user._count.positions}</td>
<td className="px-4 py-3">
<AdminUserActions user={user} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 text-sm">
{page > 1 && (
<a
href={`?q=${q}&page=${page - 1}`}
className="px-3 py-1 bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Prev
</a>
)}
<span className="text-slate-400">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<a
href={`?q=${q}&page=${page + 1}`}
className="px-3 py-1 bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Next
</a>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { priceUpdateQueue } from '@/lib/queue'
/**
* POST /api/admin/stocks/[hashtagId]/force-update
* Immediately enqueues a high-priority price-update job for this hashtag.
*/
export async function POST(_req: NextRequest, { params }: { params: { hashtagId: string } }) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const hashtag = await prisma.hashtag.findUnique({
where: { id: params.hashtagId },
select: { id: true, tag: true },
})
if (!hashtag) return NextResponse.json({ error: 'Hashtag not found.' }, { status: 404 })
await priceUpdateQueue.add(
'update-price',
{ hashtagId: hashtag.id, tag: hashtag.tag },
{
jobId: `price-${hashtag.id}-forced-${Date.now()}`,
priority: 1, // highest priority
},
)
return NextResponse.json({ ok: true })
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const schema = z.object({
currentPrice: z.number().min(0.01).optional(),
isActive: z.boolean().optional(),
})
export async function PATCH(req: NextRequest, { params }: { params: { hashtagId: string } }) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const updated = await prisma.hashtag.update({
where: { id: params.hashtagId },
data: {
...parsed.data,
// Reset zero count if manually re-activating
...(parsed.data.isActive === true ? { zeroCount: 0, lastUpdated: new Date() } : {}),
},
})
return NextResponse.json(updated)
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { addHours } from 'date-fns'
/**
* POST /api/admin/users/[userId]/reset-link
*
* Generates a one-time password reset URL for the specified user.
* The link expires in 2 hours. No email is sent — the admin copies and shares it.
*/
export async function POST(_req: NextRequest, { params }: { params: { userId: string } }) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const user = await prisma.user.findUnique({
where: { id: params.userId },
select: { id: true },
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
// Invalidate any existing unused tokens for this user
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true },
})
const reset = await prisma.passwordReset.create({
data: {
userId: user.id,
expiresAt: addHours(new Date(), 2),
},
})
const baseUrl = process.env.NEXTAUTH_URL ?? 'http://localhost:3000'
const url = `${baseUrl}/auth/reset-password?token=${reset.token}`
return NextResponse.json({ url })
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const schema = z.object({
balance: z.number().min(0).optional(),
researchPoints: z.number().int().min(0).optional(),
isAdmin: z.boolean().optional(),
})
export async function PATCH(req: NextRequest, { params }: { params: { userId: string } }) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const updated = await prisma.user.update({
where: { id: params.userId },
data: parsed.data,
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true },
})
return NextResponse.json(updated)
}
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
// Simple username validation — alphanumeric + underscores, 3-20 chars
const USERNAME_RE = /^[a-z0-9_]{3,20}$/
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null)
if (!body?.username || !body?.password) {
return NextResponse.json({ error: 'Username and password are required.' }, { status: 400 })
}
const username: string = body.username.toLowerCase().trim()
const password: string = body.password
if (!USERNAME_RE.test(username)) {
return NextResponse.json(
{ error: 'Username must be 320 chars: letters, numbers, underscores.' },
{ status: 400 },
)
}
if (password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters.' },
{ status: 400 },
)
}
const existing = await prisma.user.findUnique({ where: { username } })
if (existing) {
return NextResponse.json({ error: 'Username is already taken.' }, { status: 409 })
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.user.create({
data: { username, passwordHash },
})
return NextResponse.json({ ok: true }, { status: 201 })
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null)
if (!body?.token || !body?.password) {
return NextResponse.json({ error: 'Token and password are required.' }, { status: 400 })
}
if (body.password.length < 8) {
return NextResponse.json(
{ error: 'Password must be at least 8 characters.' },
{ status: 400 },
)
}
const reset = await prisma.passwordReset.findUnique({ where: { token: body.token } })
if (!reset || reset.used || reset.expiresAt < new Date()) {
return NextResponse.json(
{ error: 'This reset link is invalid or has expired.' },
{ status: 400 },
)
}
const passwordHash = await bcrypt.hash(body.password, 12)
await prisma.$transaction([
prisma.user.update({
where: { id: reset.userId },
data: { passwordHash },
}),
prisma.passwordReset.update({
where: { id: reset.id },
data: { used: true },
}),
])
return NextResponse.json({ ok: true })
}
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getPostsPerHour } from '@/lib/mastodon'
import { calcPrice } from '@/lib/pricing'
import { normalizeTag } from '@/lib/utils'
import { priceUpdateQueue } from '@/lib/queue'
/**
* POST /api/research
* Body: { tag: string }
*
* Deducts 1 research point, queries Mastodon, creates the hashtag if results found.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json().catch(() => null)
const raw: string = body?.tag ?? ''
const tag = normalizeTag(raw)
if (!tag || tag.length < 2 || tag.length > 100) {
return NextResponse.json({ error: 'Invalid hashtag.' }, { status: 400 })
}
// Check user has research points
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { researchPoints: true },
})
if (!user || user.researchPoints < 1) {
return NextResponse.json({ error: 'No research points remaining.' }, { status: 400 })
}
// Check if already active — no need to spend a point
const existing = await prisma.hashtag.findUnique({ where: { tag } })
if (existing?.isActive) {
return NextResponse.json({ ok: true, hashtagId: existing.id, alreadyActive: true })
}
// Query Mastodon
let postsPerHour = 0
try {
postsPerHour = await getPostsPerHour(tag)
} catch {
return NextResponse.json(
{ error: 'Could not reach Mastodon. Try again later.' },
{ status: 502 },
)
}
if (postsPerHour === 0) {
// Deduct point for failed research
await prisma.user.update({
where: { id: session.user.id },
data: { researchPoints: { decrement: 1 } },
})
return NextResponse.json(
{ error: 'No recent posts found for this hashtag. Research point spent.' },
{ status: 404 },
)
}
const price = calcPrice(postsPerHour)
// Upsert the hashtag and deduct point atomically
const [hashtag] = await prisma.$transaction([
prisma.hashtag.upsert({
where: { tag },
create: {
tag,
displayTag: raw.trim().replace(/^#+/, ''),
currentPrice: price,
isActive: true,
priceHistory: {
create: { price, postsPerHour },
},
},
update: {
isActive: true,
currentPrice: price,
zeroCount: 0,
lastUpdated: new Date(),
priceHistory: {
create: { price, postsPerHour },
},
},
}),
prisma.user.update({
where: { id: session.user.id },
data: { researchPoints: { decrement: 1 } },
}),
])
// Queue an initial price update in the background
await priceUpdateQueue.add(
'update-price',
{ hashtagId: hashtag.id, tag: hashtag.tag },
{ jobId: `price-${hashtag.id}-init` },
)
return NextResponse.json({ ok: true, hashtagId: hashtag.id, price }, { status: 201 })
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { calcTrade } from '@/lib/pricing'
import { z } from 'zod'
const tradeSchema = z.object({
hashtagId: z.string().min(1),
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
shares: z.number().positive().max(1_000_000),
})
/**
* POST /api/trade
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json().catch(() => null)
const parsed = tradeSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const { hashtagId, type, shares } = parsed.data
// Fetch hashtag and user together
const [hashtag, user] = await Promise.all([
prisma.hashtag.findUnique({ where: { id: hashtagId, isActive: true } }),
prisma.user.findUnique({ where: { id: session.user.id } }),
])
if (!hashtag) return NextResponse.json({ error: 'Hashtag not found or inactive.' }, { status: 404 })
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
const positionType = type === 'BUY_LONG' || type === 'SELL_LONG' ? 'LONG' : 'SHORT'
// Get existing position
const existingPosition = await prisma.position.findUnique({
where: { userId_hashtagId_positionType: { userId: user.id, hashtagId, positionType } },
})
const avgBuyPrice = existingPosition?.avgBuyPrice ?? hashtag.currentPrice
const { total, balanceDelta, profit } = calcTrade(type, shares, hashtag.currentPrice, avgBuyPrice)
// Validation
if ((type === 'BUY_LONG' || type === 'BUY_SHORT') && total > user.balance) {
return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 })
}
if (type === 'SELL_LONG') {
if (!existingPosition || existingPosition.shares < shares) {
return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 })
}
}
if (type === 'SELL_SHORT') {
if (!existingPosition || existingPosition.shares < shares) {
return NextResponse.json({ error: 'Insufficient short position to close.' }, { status: 400 })
}
}
// Execute trade in a transaction
await prisma.$transaction(async (tx) => {
// Update user balance
await tx.user.update({
where: { id: user.id },
data: { balance: { increment: balanceDelta } },
})
// Update / create position
if (type === 'BUY_LONG' || type === 'BUY_SHORT') {
if (existingPosition) {
// Weighted average buy price
const totalShares = existingPosition.shares + shares
const newAvg =
(existingPosition.avgBuyPrice * existingPosition.shares + hashtag.currentPrice * shares) /
totalShares
await tx.position.update({
where: { id: existingPosition.id },
data: { shares: { increment: shares }, avgBuyPrice: newAvg },
})
} else {
await tx.position.create({
data: {
userId: user.id,
hashtagId,
positionType,
shares,
avgBuyPrice: hashtag.currentPrice,
},
})
}
} else {
// SELL — reduce position
const newShares = (existingPosition?.shares ?? 0) - shares
await tx.position.update({
where: { id: existingPosition!.id },
data: { shares: newShares },
})
}
// Record trade
await tx.trade.create({
data: {
userId: user.id,
hashtagId,
type,
shares,
price: hashtag.currentPrice,
total,
profit,
},
})
})
return NextResponse.json({ ok: true })
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
/**
* GET /api/user/me — returns the current user's balance and research points
* Used by the Navbar balance badge.
*/
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { balance: true, researchPoints: true },
})
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(user)
}
+139
View File
@@ -0,0 +1,139 @@
'use client'
import { Suspense } from 'react'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { TrendingUp } from 'lucide-react'
function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
if (!token) {
return (
<div className="flex min-h-[80vh] items-center justify-center">
<div className="text-center">
<p className="text-red-400 mb-4">Invalid or missing reset token.</p>
<a href="/auth/signin" className="text-indigo-400 hover:underline">
Back to sign in
</a>
</div>
</div>
)
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (password !== confirm) {
setError('Passwords do not match.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setLoading(true)
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Reset failed.')
} else {
setSuccess(true)
setTimeout(() => router.push('/auth/signin'), 2000)
}
}
if (success) {
return (
<div className="flex min-h-[80vh] items-center justify-center">
<div className="text-center">
<p className="text-emerald-400 text-lg mb-2">Password updated!</p>
<p className="text-slate-400 text-sm">Redirecting to sign in</p>
</div>
</div>
)
}
return (
<div className="flex min-h-[80vh] items-center justify-center">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<TrendingUp className="h-8 w-8 text-indigo-500" />
<span className="text-2xl font-bold tracking-tight">HashEx</span>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl p-8">
<h1 className="text-xl font-semibold mb-6 text-center">Set new password</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">New password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="min 8 characters"
required
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Confirm password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="repeat password"
required
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg transition-colors"
>
{loading ? 'Updating…' : 'Update password'}
</button>
</form>
</div>
</div>
</div>
)
}
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
)
}
+112
View File
@@ -0,0 +1,112 @@
'use client'
import { Suspense } from 'react'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { TrendingUp } from 'lucide-react'
function SignInForm() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') ?? '/'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn('credentials', {
username: username.toLowerCase().trim(),
password,
redirect: false,
})
setLoading(false)
if (result?.error) {
setError('Invalid username or password.')
} else {
router.push(callbackUrl)
router.refresh()
}
}
return (
<div className="flex min-h-[80vh] items-center justify-center">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<TrendingUp className="h-8 w-8 text-indigo-500" />
<span className="text-2xl font-bold tracking-tight">HashEx</span>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl p-8">
<h1 className="text-xl font-semibold mb-6 text-center">Sign in</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="yourname"
required
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg transition-colors"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-6">
No account?{' '}
<Link href="/auth/signup" className="text-indigo-400 hover:text-indigo-300">
Sign up
</Link>
</p>
</div>
</div>
</div>
)
}
export default function SignInPage() {
return (
<Suspense>
<SignInForm />
</Suspense>
)
}
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { TrendingUp } from 'lucide-react'
export default function SignUpPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (password !== confirm) {
setError('Passwords do not match.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setLoading(true)
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.toLowerCase().trim(), password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Registration failed.')
setLoading(false)
return
}
// Auto sign-in after registration
const result = await signIn('credentials', {
username: username.toLowerCase().trim(),
password,
redirect: false,
})
setLoading(false)
if (result?.error) {
router.push('/auth/signin')
} else {
router.push('/')
router.refresh()
}
}
return (
<div className="flex min-h-[80vh] items-center justify-center">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<TrendingUp className="h-8 w-8 text-indigo-500" />
<span className="text-2xl font-bold tracking-tight">HashEx</span>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl p-8">
<h1 className="text-xl font-semibold mb-2 text-center">Create account</h1>
<p className="text-sm text-slate-400 text-center mb-6">
Start with <span className="text-emerald-400 font-medium">$2,000</span> to invest
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
pattern="^[a-z0-9_]{3,20}$"
title="320 characters: letters, numbers, underscores"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="yourname"
required
autoComplete="username"
/>
<p className="text-xs text-slate-500 mt-1">320 chars, letters/numbers/underscores</p>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="min 8 characters"
required
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Confirm password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="repeat password"
required
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg transition-colors"
>
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-6">
Already have an account?{' '}
<Link href="/auth/signin" className="text-indigo-400 hover:text-indigo-300">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
@apply bg-surface text-slate-100 antialiased;
}
/* Recharts tooltip dark override */
.recharts-tooltip-wrapper .recharts-default-tooltip {
background-color: #16161f !important;
border-color: #1e1e2e !important;
border-radius: 8px !important;
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Search } from 'lucide-react'
interface Props {
tag: string
researchPoints: number
}
export function ResearchPanel({ tag, researchPoints }: Props) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleResearch() {
setLoading(true)
setError('')
const res = await fetch('/api/research', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Research failed.')
} else {
router.refresh()
}
}
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center space-y-4">
<Search className="h-10 w-10 mx-auto text-indigo-400" />
<div>
<h2 className="font-semibold text-lg">Research #{tag}?</h2>
<p className="text-slate-400 text-sm mt-1">
Costs <span className="text-white font-medium">1 research point</span>.
We'll query Mastodon to see how active this tag is and set an initial price.
</p>
</div>
<div className="inline-flex items-center gap-2 text-sm bg-surface border border-surface-border rounded-lg px-4 py-2">
<span className="text-slate-400">Your research points:</span>
<span className="font-bold text-indigo-400">{researchPoints}</span>
</div>
{researchPoints <= 0 && (
<p className="text-amber-400 text-sm">
You're out of research points. You earn more daily based on your account balance.
</p>
)}
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
onClick={handleResearch}
disabled={loading || researchPoints <= 0}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-medium px-6 py-2 rounded-lg transition-colors"
>
{loading ? 'Researching…' : 'Use 1 research point'}
</button>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency, formatNumber } from '@/lib/utils'
interface Props {
hashtag: { id: string; tag: string; displayTag: string; currentPrice: number }
balance: number
longPosition: { shares: number; avgBuyPrice: number } | null
shortPosition: { shares: number; avgBuyPrice: number } | null
}
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Props) {
const router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const sharesNum = parseFloat(shares) || 0
const cost = sharesNum * hashtag.currentPrice
const maxBuyShares = hashtag.currentPrice > 0 ? Math.floor((balance / hashtag.currentPrice) * 100) / 100 : 0
const maxSellShares =
tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0
const canAfford =
tab === 'BUY_LONG' || tab === 'BUY_SHORT' ? cost <= balance : sharesNum <= (maxSellShares ?? 0)
async function handleTrade() {
if (sharesNum <= 0) return
setLoading(true)
setError('')
const res = await fetch('/api/trade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Trade failed.')
} else {
setShares('')
router.refresh()
}
}
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400">
Balance: <span className="text-white font-medium">{formatCurrency(balance)}</span>
</span>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-surface rounded-lg p-1">
{(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => { setTab(t); setShares(''); setError('') }}
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors ${
tab === t
? t.startsWith('BUY')
? 'bg-emerald-600 text-white'
: 'bg-red-600 text-white'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t.replace('_', ' ')}
</button>
))}
</div>
{/* Current positions info */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">LONG position</p>
{longPosition ? (
<>
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
</>
) : (
<p className="text-slate-600">None</p>
)}
</div>
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">SHORT position</p>
{shortPosition ? (
<>
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
</>
) : (
<p className="text-slate-600">None</p>
)}
</div>
</div>
{/* Shares input */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm text-slate-400">Shares</label>
<button
type="button"
className="text-xs text-indigo-400 hover:text-indigo-300"
onClick={() => setShares(
tab === 'BUY_LONG' || tab === 'BUY_SHORT'
? String(maxBuyShares)
: String(maxSellShares)
)}
>
Max
</button>
</div>
<input
type="number"
min="0.01"
step="0.01"
value={shares}
onChange={(e) => setShares(e.target.value)}
placeholder="0.00"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{sharesNum > 0 && (
<p className="text-xs text-slate-400 mt-1">
Total: {formatCurrency(cost)}{' '}
{!canAfford && <span className="text-red-400"> insufficient funds/shares</span>}
</p>
)}
</div>
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<button
onClick={handleTrade}
disabled={loading || sharesNum <= 0 || !canAfford}
className={`w-full font-medium py-2 rounded-lg transition-colors disabled:opacity-50 ${
tab.startsWith('BUY')
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
: 'bg-red-600 hover:bg-red-500 text-white'
}`}
>
{loading ? 'Processing…' : `${tab.replace('_', ' ')} @ ${formatCurrency(hashtag.currentPrice)}`}
</button>
</div>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber } from '@/lib/utils'
import { PriceChart } from '@/components/PriceChart'
import { TradePanel } from './TradePanel'
import { ResearchPanel } from './ResearchPanel'
import { Hash, Clock } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
export const dynamic = 'force-dynamic'
interface Props {
params: { tag: string }
}
export default async function HashtagPage({ params }: Props) {
const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const [hashtag, userBalance, userPosition] = await Promise.all([
prisma.hashtag.findUnique({
where: { tag },
include: {
priceHistory: {
orderBy: { recordedAt: 'asc' },
take: 200,
},
_count: {
select: { positions: true },
},
},
}),
session
? prisma.user.findUnique({
where: { id: session.user.id },
select: { balance: true, researchPoints: true },
})
: null,
session
? prisma.position.findMany({
where: { userId: session.user.id, hashtagId: { not: undefined } },
})
: [],
])
// Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="text-center py-12">
<Hash className="h-12 w-12 mx-auto mb-3 text-slate-600" />
<h1 className="text-2xl font-bold mb-2">#{tag}</h1>
<p className="text-slate-400">
{hashtag && !hashtag.isActive
? 'This hashtag is inactive — no one currently holds a position. Research it again to reactivate.'
: 'This hashtag isn\'t tracked yet.'}
</p>
</div>
{session ? (
<ResearchPanel
tag={tag}
researchPoints={userBalance?.researchPoints ?? 0}
/>
) : (
<div className="text-center">
<a href="/auth/signin" className="text-indigo-400 hover:underline">
Sign in to research this hashtag
</a>
</div>
)}
</div>
)
}
const positions = (userPosition as { hashtagId: string; positionType: string; shares: number; avgBuyPrice: number }[]).filter(
(p) => p.hashtagId === hashtag.id && p.shares > 0,
)
const longPosition = positions.find((p) => p.positionType === 'LONG')
const shortPosition = positions.find((p) => p.positionType === 'SHORT')
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">#{hashtag.displayTag}</h1>
<div className="flex items-center gap-3 mt-1 text-slate-400 text-sm">
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
Updated {formatDistanceToNow(new Date(hashtag.lastUpdated), { addSuffix: true })}
</span>
<span>{hashtag._count.positions} investor{hashtag._count.positions !== 1 ? 's' : ''}</span>
</div>
</div>
<div className="text-right">
<p className="text-4xl font-bold">{formatCurrency(hashtag.currentPrice)}</p>
<p className="text-sm text-slate-400">per share</p>
</div>
</div>
{/* Chart */}
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
<PriceChart
data={hashtag.priceHistory.map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
height={280}
/>
</div>
{/* Trade panel or sign-in prompt */}
{session ? (
<TradePanel
hashtag={{
id: hashtag.id,
tag: hashtag.tag,
displayTag: hashtag.displayTag,
currentPrice: hashtag.currentPrice,
}}
balance={userBalance?.balance ?? 0}
longPosition={
longPosition
? { shares: longPosition.shares, avgBuyPrice: longPosition.avgBuyPrice }
: null
}
shortPosition={
shortPosition
? { shares: shortPosition.shares, avgBuyPrice: shortPosition.avgBuyPrice }
: null
}
/>
) : (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
<p className="text-slate-400 mb-3">Sign in to trade this hashtag.</p>
<a
href="/auth/signin"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-5 py-2 rounded-lg text-sm transition-colors inline-block"
>
Sign in
</a>
</div>
)}
{/* Recent trades */}
<RecentTradesSection hashtagId={hashtag.id} />
</div>
)
}
async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
const trades = await prisma.trade.findMany({
where: { hashtagId },
orderBy: { createdAt: 'desc' },
take: 20,
include: { user: { select: { username: true } } },
})
if (trades.length === 0) return null
return (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Recent trades
</h2>
<div className="divide-y divide-surface-border">
{trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-3">
<span
className={`text-xs font-medium px-2 py-0.5 rounded ${
t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}
>
{t.type.replace('_', ' ')}
</span>
<a
href={`/profile/${t.user.username}`}
className="text-slate-400 hover:text-slate-200"
>
{t.user.username}
</a>
</div>
<div className="text-right">
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
<span className="text-slate-500 ml-2">@ {formatCurrency(t.price)}</span>
</div>
</div>
))}
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
import { Navbar } from '@/components/Navbar'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'HashEx — The Hashtag Exchange',
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<body className={inter.className}>
<Providers>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">{children}</main>
</Providers>
</body>
</html>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { prisma } from '@/lib/prisma'
import { HashtagCard } from '@/components/HashtagCard'
import { TrendingUp, Users, Hash } from 'lucide-react'
import Link from 'next/link'
export const dynamic = 'force-dynamic'
export const revalidate = 0
async function getStats() {
const [userCount, hashtagCount, tradeCount, topHashtags, recentTrades] = await Promise.all([
prisma.user.count(),
prisma.hashtag.count({ where: { isActive: true } }),
prisma.trade.count(),
// Top by current price (most active)
prisma.hashtag.findMany({
where: { isActive: true },
orderBy: { currentPrice: 'desc' },
take: 12,
include: {
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 2,
},
},
}),
// Recently traded
prisma.trade.findMany({
orderBy: { createdAt: 'desc' },
take: 8,
include: { hashtag: true },
distinct: ['hashtagId'],
}),
])
return { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }
}
export default async function HomePage() {
const { userCount, hashtagCount, tradeCount, topHashtags, recentTrades } = await getStats()
return (
<div className="space-y-10">
{/* Hero */}
<div className="text-center py-8">
<h1 className="text-4xl font-bold tracking-tight mb-3">
The{' '}
<span className="text-indigo-400">Hashtag</span> Exchange
</h1>
<p className="text-slate-400 max-w-xl mx-auto">
Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon.
Research a tag to unlock it, then buy long or short.
</p>
<div className="flex justify-center gap-4 mt-6">
<Link
href="/auth/signup"
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Get started
</Link>
<Link
href="/auth/signin"
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Sign in
</Link>
</div>
</div>
{/* Platform stats */}
<div className="grid grid-cols-3 gap-4">
<StatCard icon={<Users className="h-5 w-5 text-indigo-400" />} label="Players" value={userCount.toLocaleString()} />
<StatCard icon={<Hash className="h-5 w-5 text-indigo-400" />} label="Active hashtags" value={hashtagCount.toLocaleString()} />
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
</div>
{/* Top hashtags */}
{topHashtags.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-400" />
Trending now
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{topHashtags.map((h) => {
const prev = h.priceHistory[1]?.price ?? undefined
return (
<HashtagCard
key={h.id}
tag={h.tag}
displayTag={h.displayTag}
currentPrice={h.currentPrice}
previousPrice={prev}
/>
)
})}
</div>
</section>
)}
{/* Recently traded */}
{recentTrades.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4">Recently traded</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{recentTrades.map(({ hashtag }) => (
<HashtagCard
key={hashtag.id}
tag={hashtag.tag}
displayTag={hashtag.displayTag}
currentPrice={hashtag.currentPrice}
/>
))}
</div>
</section>
)}
{topHashtags.length === 0 && (
<div className="text-center py-16 text-slate-500">
<Hash className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No hashtags tracked yet.</p>
<p className="text-sm mt-1">Sign up and use a research point to add the first one.</p>
</div>
)}
</div>
)
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-center gap-3">
{icon}
<div>
<p className="text-xl font-bold">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
export const dynamic = 'force-dynamic'
interface Props {
params: { username: string }
}
export default async function ProfilePage({ params }: Props) {
const session = await getServerSession(authOptions)
const username = decodeURIComponent(params.username).toLowerCase()
const user = await prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
balance: true,
researchPoints: true,
createdAt: true,
positions: {
where: { shares: { gt: 0 } },
include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true } } },
orderBy: { updatedAt: 'desc' },
},
trades: {
orderBy: { createdAt: 'desc' },
take: 30,
include: { hashtag: { select: { tag: true, displayTag: true } } },
},
},
})
if (!user) notFound()
const isOwn = session?.user.id === user.id
// Calculate portfolio value and unrealized P&L
const portfolioValue = user.positions.reduce((sum, p) => {
return sum + p.shares * p.hashtag.currentPrice
}, 0)
const unrealizedPnl = user.positions.reduce((sum, p) => {
if (p.positionType === 'LONG') {
return sum + (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
} else {
// SHORT
return sum + (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
}
}, 0)
const totalValue = user.balance + portfolioValue
const realizedPnl = user.trades
.filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT')
.reduce((sum, t) => sum + t.profit, 0)
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">{user.username}</h1>
{isOwn && (
<p className="text-slate-400 text-sm mt-1">
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
</p>
)}
</div>
<div className="text-right">
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p>
<p className="text-sm text-slate-400">total portfolio value</p>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatChip label="Cash" value={formatCurrency(user.balance)} />
<StatChip label="Invested" value={formatCurrency(portfolioValue)} />
<StatChip
label="Unrealized P&L"
value={formatPnl(unrealizedPnl)}
colorClass={pnlColor(unrealizedPnl)}
/>
<StatChip
label="Realized P&L"
value={formatPnl(realizedPnl)}
colorClass={pnlColor(realizedPnl)}
/>
</div>
{/* Positions */}
{user.positions.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Coins className="h-5 w-5 text-indigo-400" />
Open positions
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{user.positions.map((pos) => {
const pnl =
pos.positionType === 'LONG'
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
return (
<div key={pos.id} className="flex items-center justify-between px-4 py-3">
<div>
<Link
href={`/hashtag/${pos.hashtag.tag}`}
className="font-medium hover:text-indigo-300"
>
#{pos.hashtag.displayTag}
</Link>
<p className="text-xs text-slate-500 mt-0.5">
{formatNumber(pos.shares)} shares {' '}
{pos.positionType === 'LONG' ? (
<span className="text-emerald-400">LONG</span>
) : (
<span className="text-red-400">SHORT</span>
)}{' '}
avg {formatCurrency(pos.avgBuyPrice)}
</p>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(pos.hashtag.currentPrice)}</p>
<p className={`text-xs ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
</div>
</div>
)
})}
</div>
</div>
</section>
)}
{/* Trade history */}
{user.trades.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
{isOwn ? (
<TrendingUp className="h-5 w-5 text-indigo-400" />
) : (
<TrendingDown className="h-5 w-5 text-indigo-400" />
)}
Trade history
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{user.trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<span
className={`text-xs font-medium px-2 py-0.5 rounded ${
t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{t.type.replace('_', ' ')}
</span>
<Link
href={`/hashtag/${t.hashtag.tag}`}
className="hover:text-indigo-300"
>
#{t.hashtag.displayTag}
</Link>
</div>
<div className="text-right">
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
<p className={`text-xs ${pnlColor(t.profit)}`}>
{formatPnl(t.profit)}
</p>
)}
</div>
</div>
))}
</div>
</div>
</section>
)}
</div>
)
}
function StatChip({
label,
value,
colorClass,
}: {
label: string
value: string
colorClass?: string
}) {
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className={`text-lg font-bold ${colorClass ?? ''}`}>{value}</p>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
+54
View File
@@ -0,0 +1,54 @@
import Link from 'next/link'
import { TrendingUp, TrendingDown } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
interface Props {
tag: string
displayTag: string
currentPrice: number
previousPrice?: number
postsPerHour?: number
}
export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, postsPerHour }: Props) {
const pctChange =
previousPrice && previousPrice > 0
? ((currentPrice - previousPrice) / previousPrice) * 100
: null
const up = pctChange === null ? null : pctChange >= 0
return (
<Link
href={`/hashtag/${tag}`}
className="block bg-surface-card border border-surface-border hover:border-indigo-500/50 rounded-xl p-4 transition-all hover:shadow-lg hover:shadow-indigo-500/5"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-sm">#{displayTag}</p>
{postsPerHour !== undefined && (
<p className="text-xs text-slate-500 mt-0.5">
{postsPerHour.toFixed(1)} posts/hr
</p>
)}
</div>
<div className="text-right">
<p className="font-bold text-sm">{formatCurrency(currentPrice)}</p>
{pctChange !== null && (
<div
className={`flex items-center justify-end gap-0.5 text-xs mt-0.5 ${up ? 'text-emerald-400' : 'text-red-400'}`}
>
{up ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{up ? '+' : ''}
{pctChange.toFixed(1)}%
</div>
)}
</div>
</div>
</Link>
)
}
+117
View File
@@ -0,0 +1,117 @@
'use client'
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield } from 'lucide-react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
import { normalizeTag } from '@/lib/utils'
export function Navbar() {
const { data: session } = useSession()
const router = useRouter()
const [query, setQuery] = useState('')
function handleSearch(e: React.FormEvent) {
e.preventDefault()
const tag = normalizeTag(query)
if (tag) {
router.push(`/hashtag/${tag}`)
setQuery('')
}
}
return (
<nav className="border-b border-surface-border bg-surface-card">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14 gap-4">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<TrendingUp className="h-6 w-6 text-indigo-500" />
<span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="#hashtag"
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</form>
{/* Right section */}
<div className="flex items-center gap-3 shrink-0">
{session ? (
<>
{/* Balance chip */}
<BalanceBadge userId={session.user.id} />
{session.user.isAdmin && (
<Link
href="/admin"
className="text-amber-400 hover:text-amber-300 transition-colors"
title="Admin dashboard"
>
<Shield className="h-5 w-5" />
</Link>
)}
<Link
href={`/profile/${session.user.username}`}
className="text-slate-400 hover:text-slate-200 transition-colors"
title="Profile"
>
<User className="h-5 w-5" />
</Link>
<button
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
className="text-slate-400 hover:text-slate-200 transition-colors"
title="Sign out"
>
<LogOut className="h-5 w-5" />
</button>
</>
) : (
<Link
href="/auth/signin"
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
Sign in
</Link>
)}
</div>
</div>
</div>
</nav>
)
}
// Lazy balance fetcher so the navbar always shows current value
function BalanceBadge({ userId }: { userId: string }) {
// We read balance from the API to stay fresh; use SWR-style approach
const [balance, setBalance] = useState<number | null>(null)
// One-shot fetch on mount
if (typeof window !== 'undefined' && balance === null) {
fetch('/api/user/me')
.then((r) => r.json())
.then((d) => setBalance(d.balance ?? null))
.catch(() => {})
}
if (balance === null) return null
return (
<span className="text-emerald-400 text-sm font-medium hidden md:block">
{formatCurrency(balance)}
</span>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ReferenceLine,
} from 'recharts'
import { format } from 'date-fns'
import { formatCurrency } from '@/lib/utils'
interface PricePoint {
recordedAt: string
price: number
}
interface Props {
data: PricePoint[]
height?: number
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: { value: number; payload: PricePoint }[]
}) {
if (!active || !payload?.length) return null
const point = payload[0]
return (
<div className="bg-surface-card border border-surface-border rounded-lg p-3 text-sm">
<p className="text-slate-400">{format(new Date(point.payload.recordedAt), 'MMM d, HH:mm')}</p>
<p className="font-semibold text-white">{formatCurrency(point.value)}</p>
</div>
)
}
export function PriceChart({ data, height = 250 }: Props) {
if (data.length === 0) {
return (
<div
className="flex items-center justify-center text-slate-500 text-sm bg-surface-card border border-surface-border rounded-xl"
style={{ height }}
>
No price history yet
</div>
)
}
const prices = data.map((d) => d.price)
const minPrice = Math.min(...prices)
const maxPrice = Math.max(...prices)
const firstPrice = data[0]?.price ?? 0
const lastPrice = data[data.length - 1]?.price ?? 0
const trending = lastPrice >= firstPrice
const chartData = data.map((d) => ({
...d,
time: new Date(d.recordedAt).getTime(),
}))
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e2e" />
<XAxis
dataKey="time"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={(t) => format(new Date(t), 'MMM d')}
stroke="#475569"
tick={{ fill: '#94a3b8', fontSize: 11 }}
/>
<YAxis
domain={[Math.max(0, minPrice * 0.9), maxPrice * 1.1]}
tickFormatter={(v) => `$${v.toFixed(2)}`}
stroke="#475569"
tick={{ fill: '#94a3b8', fontSize: 11 }}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
{data.length > 0 && (
<ReferenceLine y={firstPrice} stroke="#475569" strokeDasharray="4 4" />
)}
<Line
type="monotone"
dataKey="price"
stroke={trending ? '#34d399' : '#f87171'}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
</LineChart>
</ResponsiveContainer>
)
}
+59
View File
@@ -0,0 +1,59 @@
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
export const authOptions: NextAuthOptions = {
session: { strategy: 'jwt' },
pages: {
signIn: '/auth/signin',
error: '/auth/signin',
},
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) return null
const user = await prisma.user.findUnique({
where: { username: credentials.username.toLowerCase().trim() },
})
if (!user) return null
const passwordMatch = await bcrypt.compare(credentials.password, user.passwordHash)
if (!passwordMatch) return null
return {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
name: user.username,
email: null,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.username = (user as { username: string }).username
token.isAdmin = (user as { isAdmin: boolean }).isAdmin
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string
session.user.username = token.username as string
session.user.isAdmin = token.isAdmin as boolean
}
return session
},
},
}
+84
View File
@@ -0,0 +1,84 @@
export interface MastodonPost {
id: string
created_at: string
content: string
}
interface TimelineResult {
posts: MastodonPost[]
nextMaxId: string | null
}
function extractMaxId(linkHeader: string | null): string | null {
if (!linkHeader) return null
// Link: <https://...?max_id=12345>; rel="next"
const match = linkHeader.match(/<[^>]*[?&]max_id=(\d+)[^>]*>;\s*rel="next"/)
return match ? match[1] : null
}
async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
const instance = process.env.MASTODON_INSTANCE
if (!instance) throw new Error('MASTODON_INSTANCE is not configured')
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=40`
if (maxId) url += `&max_id=${maxId}`
const headers: HeadersInit = { Accept: 'application/json' }
const token = process.env.MASTODON_ACCESS_TOKEN
if (token) headers['Authorization'] = `Bearer ${token}`
const response = await fetch(url, { headers, next: { revalidate: 0 } })
if (response.status === 404 || response.status === 422) {
return { posts: [], nextMaxId: null }
}
if (!response.ok) {
throw new Error(`Mastodon API error: ${response.status} ${response.statusText}`)
}
const posts: MastodonPost[] = await response.json()
const nextMaxId = extractMaxId(response.headers.get('Link'))
return { posts, nextMaxId }
}
/**
* Fetches recent posts for a hashtag and returns posts-per-hour.
* Paginates when all fetched posts share a very tight timestamp window
* (e.g., #happynewyear at midnight) up to MAX_PAGES_PER_HASHTAG pages.
*/
export async function getPostsPerHour(tag: string): Promise<number> {
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
let allPosts: MastodonPost[] = []
let maxId: string | undefined
for (let page = 0; page < maxPages; page++) {
const { posts, nextMaxId } = await fetchPage(tag, maxId)
if (posts.length === 0) break
allPosts = [...allPosts, ...posts]
// Stop paginating if we got fewer than 40 posts (end of timeline)
if (posts.length < 40 || !nextMaxId) break
// Stop paginating if the time span of what we have is already > 5 minutes
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const spanMs = Math.max(...times) - Math.min(...times)
if (spanMs > 5 * 60 * 1000) break
maxId = nextMaxId
}
if (allPosts.length === 0) return 0
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const newestMs = Math.max(...times)
const oldestMs = Math.min(...times)
// Minimum 1-minute span to handle flood scenario (all same timestamp)
const spanHours = Math.max((newestMs - oldestMs) / (1000 * 60 * 60), 1 / 60)
return allPosts.length / spanHours
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Converts posts-per-hour to a share price.
*
* Linear scale: $0.25 per post/hour, minimum $0.25.
* Examples:
* 1 post/hr → $0.25
* 10 posts/hr → $2.50
* 100 → $25.00
* 1000 → $250.00
* 12 000 (viral #happynewyear) → $3 000
*/
export function calcPrice(postsPerHour: number): number {
if (postsPerHour <= 0) return 0.25
return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100)
}
/**
* Research-point milestone: how many points a user earns per day
* based on their current balance.
*/
export function dailyResearchPoints(balance: number): number {
if (balance >= 1_000_000) return 5
if (balance >= 100_000) return 3
if (balance >= 10_000) return 2
return 1
}
/**
* Calculate the cost/proceeds and realized P&L for a trade.
*
* For LONG:
* BUY: cost = price * shares (deducted from balance)
* SELL: proceeds = price * shares (added to balance)
*
* For SHORT (simplified collateral model):
* BUY: collateral = price * shares (deducted from balance)
* SELL (close): returned = max(0, 2 * avgBuyPrice - price) * shares
* profit = returned - collateral
*/
export function calcTrade(
type: 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT',
shares: number,
price: number,
avgBuyPrice: number,
): { total: number; balanceDelta: number; profit: number } {
switch (type) {
case 'BUY_LONG': {
const total = price * shares
return { total, balanceDelta: -total, profit: 0 }
}
case 'SELL_LONG': {
const total = price * shares
const profit = (price - avgBuyPrice) * shares
return { total, balanceDelta: total, profit }
}
case 'BUY_SHORT': {
const total = price * shares
return { total, balanceDelta: -total, profit: 0 }
}
case 'SELL_SHORT': {
const returned = Math.max(0, (2 * avgBuyPrice - price) * shares)
const profit = returned - avgBuyPrice * shares
return { total: returned, balanceDelta: returned, profit }
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+44
View File
@@ -0,0 +1,44 @@
import { Queue } from 'bullmq'
// BullMQ v5 bundles its own ioredis internally.
// Pass a connection options object (URL parsed) to avoid version-mismatch issues.
function redisOpts() {
const url = process.env.REDIS_URL ?? 'redis://localhost:6379'
const parsed = new URL(url)
return {
host: parsed.hostname,
port: parseInt(parsed.port || '6379', 10),
password: parsed.password || undefined,
username: parsed.username || undefined,
db: parsed.pathname ? parseInt(parsed.pathname.slice(1) || '0', 10) : 0,
maxRetriesPerRequest: null as null,
enableReadyCheck: false,
}
}
// Queues — imported by both web app (for stats) and worker (for processing)
export const priceUpdateQueue = new Queue('hashex-price-updates', {
connection: redisOpts(),
defaultJobOptions: {
removeOnComplete: { count: 100 },
removeOnFail: { count: 50 },
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
},
})
export const maintenanceQueue = new Queue('hashex-maintenance', {
connection: redisOpts(),
defaultJobOptions: {
removeOnComplete: { count: 10 },
removeOnFail: { count: 10 },
},
})
export const schedulerQueue = new Queue('hashex-scheduler', {
connection: redisOpts(),
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: { count: 5 },
},
})
+38
View File
@@ -0,0 +1,38 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
export function formatNumber(value: number, decimals = 2): string {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value)
}
export function formatPnl(value: number): string {
const prefix = value >= 0 ? '+' : ''
return `${prefix}${formatCurrency(value)}`
}
export function pnlColor(value: number): string {
if (value > 0) return 'text-emerald-400'
if (value < 0) return 'text-red-400'
return 'text-slate-400'
}
/** Normalize a hashtag: lowercase, strip leading #, trim whitespace */
export function normalizeTag(raw: string): string {
return raw.trim().replace(/^#+/, '').toLowerCase()
}
+12
View File
@@ -0,0 +1,12 @@
export { default } from 'next-auth/middleware'
export const config = {
matcher: [
'/profile/:path*',
'/admin/:path*',
'/api/trade/:path*',
'/api/research/:path*',
'/api/user/:path*',
'/api/admin/:path*',
],
}
+25
View File
@@ -0,0 +1,25 @@
import { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
username: string
isAdmin: boolean
} & DefaultSession['user']
}
interface User {
id: string
username: string
isAdmin: boolean
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string
username: string
isAdmin: boolean
}
}
+240
View File
@@ -0,0 +1,240 @@
/**
* Background worker — runs as a separate process in its own container.
*
* Responsibilities:
* 1. hashex-price-updates — updates the price of one hashtag from Mastodon
* 2. hashex-maintenance — daily tasks (award research points)
* 3. hashex-scheduler — periodic trigger that enqueues price-update jobs
*
* Rate limiting: WORKER_RATE_LIMIT_MS ms between Mastodon calls (default 2 s).
* Jobs are ordered by lastUpdated ASC so the most-stale hashtag is always next.
*/
import { Worker, Queue } from 'bullmq'
import { PrismaClient } from '@prisma/client'
import { getPostsPerHour } from '../lib/mastodon'
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
// ── Connection options ────────────────────────────────────────────────────────
// Use plain connection options so BullMQ uses its own bundled ioredis,
// avoiding version-mismatch type errors.
function redisOpts() {
const url = process.env.REDIS_URL ?? 'redis://localhost:6379'
const parsed = new URL(url)
return {
host: parsed.hostname,
port: parseInt(parsed.port || '6379', 10),
password: parsed.password || undefined,
username: parsed.username || undefined,
db: parsed.pathname ? parseInt(parsed.pathname.slice(1) || '0', 10) : 0,
maxRetriesPerRequest: null as null,
enableReadyCheck: false,
}
}
const connection = redisOpts()
const prisma = new PrismaClient({
log: ['error', 'warn'],
})
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
// ── Queues (worker side) ──────────────────────────────────────────────────────
const priceUpdateQueue = new Queue('hashex-price-updates', { connection })
const maintenanceQueue = new Queue('hashex-maintenance', { connection })
const schedulerQueue = new Queue('hashex-scheduler', { connection })
// ── Workers ───────────────────────────────────────────────────────────────────
/**
* Price update worker — one job per active hashtag.
* Rate-limited by a concurrency of 1 + a delay between jobs.
*/
const priceWorker = new Worker(
'hashex-price-updates',
async (job) => {
const { hashtagId, tag } = job.data as { hashtagId: string; tag: string }
console.log(`[price] updating #${tag}`)
let postsPerHour = 0
try {
postsPerHour = await getPostsPerHour(tag)
} catch (err) {
console.error(`[price] mastodon error for #${tag}:`, err)
throw err // BullMQ will retry
}
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
if (!hashtag) return
if (postsPerHour === 0) {
const newZeroCount = hashtag.zeroCount + 1
// Auto-deactivate after 3 consecutive zero-result updates with no owners
const ownerCount = await prisma.position.count({
where: { hashtagId, shares: { gt: 0 } },
})
const shouldDeactivate = newZeroCount >= 3 && ownerCount === 0
await prisma.hashtag.update({
where: { id: hashtagId },
data: {
zeroCount: newZeroCount,
isActive: shouldDeactivate ? false : hashtag.isActive,
lastUpdated: new Date(),
},
})
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated' : ''}`)
return
}
const newPrice = calcPrice(postsPerHour)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId },
data: {
currentPrice: newPrice,
zeroCount: 0,
lastUpdated: new Date(),
},
}),
prisma.priceHistory.create({
data: { hashtagId, price: newPrice, postsPerHour },
}),
])
console.log(`[price] #${tag}$${newPrice.toFixed(2)} (${postsPerHour.toFixed(1)} posts/hr)`)
// Honour the configured rate limit by sleeping after each job
if (RATE_LIMIT_MS > 0) {
await new Promise((r) => setTimeout(r, RATE_LIMIT_MS))
}
},
{
connection,
concurrency: 1, // one Mastodon call at a time
},
)
/**
* Daily maintenance worker — awards research points based on balance milestones.
*/
const maintenanceWorker = new Worker(
'hashex-maintenance',
async (job) => {
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
const users = await prisma.user.findMany({ select: { id: true, balance: true } })
for (const user of users) {
const points = dailyResearchPoints(user.balance)
await prisma.user.update({
where: { id: user.id },
data: { researchPoints: { increment: points } },
})
}
console.log(`[maintenance] awarded research points to ${users.length} users`)
},
{ connection },
)
/**
* Scheduler worker — triggered on a timer to enqueue price-update jobs.
* Orders hashtags by lastUpdated ASC so the most stale ones go first.
*/
const schedulerWorker = new Worker(
'hashex-scheduler',
async (job) => {
console.log(`[scheduler] enqueueing price updates (job ${job.id})`)
const hashtags = await prisma.hashtag.findMany({
where: { isActive: true },
orderBy: { lastUpdated: 'asc' },
select: { id: true, tag: true },
})
if (hashtags.length === 0) {
console.log('[scheduler] no active hashtags to update')
return
}
// Remove any already-waiting jobs to avoid duplicates
const waiting = await priceUpdateQueue.getJobs(['waiting', 'delayed'])
const waitingIds = new Set(waiting.map((j) => j.data?.hashtagId))
const toQueue = hashtags.filter((h) => !waitingIds.has(h.id))
for (const hashtag of toQueue) {
await priceUpdateQueue.add(
'update-price',
{ hashtagId: hashtag.id, tag: hashtag.tag },
{ jobId: `price-${hashtag.id}` }, // deduplicate by jobId
)
}
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already waiting)`)
},
{ connection },
)
// ── Error handlers ────────────────────────────────────────────────────────────
for (const worker of [priceWorker, maintenanceWorker, schedulerWorker]) {
worker.on('failed', (job, err) => {
console.error(`[worker] job ${job?.id} failed:`, err.message)
})
}
// ── Repeatable jobs ───────────────────────────────────────────────────────────
async function setupRepeatableJobs() {
// Price update sweep — every N minutes
await schedulerQueue.add(
'trigger-sweep',
{},
{
repeat: { every: UPDATE_INTERVAL_MIN * 60 * 1000 },
jobId: 'price-sweep-repeatable',
},
)
// Daily maintenance — every day at 00:05 UTC
await maintenanceQueue.add(
'daily-maintenance',
{},
{
repeat: { pattern: '5 0 * * *' },
jobId: 'daily-maintenance-repeatable',
},
)
// Immediately trigger a sweep on startup so prices are fresh
await schedulerQueue.add('trigger-sweep', {}, { jobId: `sweep-startup-${Date.now()}` })
console.log(
`[worker] started — sweep every ${UPDATE_INTERVAL_MIN}min, rate limit ${RATE_LIMIT_MS}ms`,
)
}
setupRepeatableJobs().catch((err) => {
console.error('[worker] failed to set up repeatable jobs:', err)
process.exit(1)
})
// ── Graceful shutdown ─────────────────────────────────────────────────────────
async function shutdown() {
console.log('[worker] shutting down…')
await priceWorker.close()
await maintenanceWorker.close()
await schedulerWorker.close()
await prisma.$disconnect()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
+24
View File
@@ -0,0 +1,24 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
surface: {
DEFAULT: '#0f0f17',
card: '#16161f',
border: '#1e1e2e',
hover: '#1a1a26',
},
},
},
},
plugins: [],
}
export default config
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long