This commit is contained in:
@@ -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
|
||||
@@ -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."
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
dist/
|
||||
.DS_Store
|
||||
+22
@@ -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"]
|
||||
@@ -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)
|
||||
```
|
||||
Vendored
+5
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@prisma/client', 'bcryptjs'],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
Generated
+3150
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 3–20 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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="3–20 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">3–20 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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*',
|
||||
],
|
||||
}
|
||||
Vendored
+25
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user