feat: add CoopCredits Solana media rewards ecosystem
Add complete token system with Plex/Tautulli/Overseer integration: - Anchor program for SPL token mint/burn/transfer - Express backend with OAuth, webhooks, Solana integration - Next.js frontend with dashboard, admin panel, wallet management - Docker deployment for 172.20.1.0/24 infrastructure - Production configs with SSL, Nginx, health monitoring Tautulli webhooks auto-mint on watch events. Overseer integration burns for content requests.
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Solana
|
||||
.anchor/
|
||||
target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# SSL certificates (add your own)
|
||||
docker/nginx/ssl/*.pem
|
||||
docker/nginx/ssl/*.key
|
||||
docker/nginx/ssl/*.crt
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
temp/
|
||||
tmp/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Pi-lens cache
|
||||
.pi-lens/
|
||||
|
||||
# Keep directory structure
|
||||
!.gitkeep
|
||||
@@ -0,0 +1,168 @@
|
||||
# CoopCredits Infrastructure Configuration Summary
|
||||
|
||||
## Your Server Infrastructure
|
||||
|
||||
| Service | IP Address | Port | Integration Role |
|
||||
|----------|----------------|------|--------------------------------|
|
||||
| Plex | 172.20.1.220 | 32400| User authentication, content |
|
||||
| Overseer | 172.20.1.225 | 5055 | Content requests, $COOP spend |
|
||||
| Tautulli | 172.20.1.255 | 8181 | Watch tracking, $COOP earn |
|
||||
| Website | coop.hobokenchicken.com | 443 | User dashboard, admin panel |
|
||||
|
||||
## Files Created/Updated
|
||||
|
||||
### Configuration Files
|
||||
- `.env.production` - Production environment template
|
||||
- `docker-compose.prod.yml` - Production Docker orchestration
|
||||
- `docker/nginx/nginx.prod.conf` - Nginx reverse proxy config
|
||||
|
||||
### Deployment Scripts
|
||||
- `deployment/setup-infrastructure.sh` - Initial infrastructure setup
|
||||
- `deployment/deploy-production.sh` - Production deployment
|
||||
- `deployment/health-check.sh` - Service health monitoring
|
||||
|
||||
### Documentation
|
||||
- `docs/SETUP-INFRASTRUCTURE.md` - Step-by-step setup guide
|
||||
- `docs/INFRASTRUCTURE.md` - Architecture and network documentation
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Nginx │ (80/443) - SSL termination, rate limiting
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌───┴───┐
|
||||
▼ ▼
|
||||
┌──────┐ ┌──────┐
|
||||
│Frontend│ │Backend│
|
||||
│:3000 │ │:3001 │
|
||||
└──────┘ └───┬───┘
|
||||
│
|
||||
┌──────┼──────┐
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────┐ ┌──────┐
|
||||
│PostgreSQL│ │Redis│ │External│
|
||||
│:5432 │ │:6379│ │Network │
|
||||
└────────┘ └────┘ └───┬───┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Plex │ │Tautulli │ │ Overseer│
|
||||
│172.20.1.│ │172.20.1.│ │172.20.1.│
|
||||
│ 220 │ │ 255 │ │ 225 │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Quick Deployment Commands
|
||||
|
||||
```bash
|
||||
# 1. Setup infrastructure
|
||||
npm run setup:infra
|
||||
|
||||
# 2. Edit .env with your API keys
|
||||
nano .env
|
||||
|
||||
# 3. Setup Solana
|
||||
npm run setup:solana
|
||||
|
||||
# 4. Deploy
|
||||
npm run deploy:prod
|
||||
|
||||
# 5. Check health
|
||||
npm run health
|
||||
|
||||
# 6. Watch mode monitoring
|
||||
npm run health -- --watch
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Tautulli → CoopCredits
|
||||
- **Trigger**: Watch events
|
||||
- **Webhook URL**: `https://coop.hobokenchicken.com/webhooks/tautulli`
|
||||
- **Action**: Mint $COOP tokens
|
||||
|
||||
### Overseer → CoopCredits
|
||||
- **Trigger**: Request approval/decline
|
||||
- **Webhook URL**: `https://coop.hobokenchicken.com/webhooks/overseer`
|
||||
- **Action**: Burn/spend $COOP tokens
|
||||
|
||||
### Plex → CoopCredits
|
||||
- **Trigger**: User login
|
||||
- **Method**: OAuth via plex.tv
|
||||
- **Action**: Authenticate users
|
||||
|
||||
## Security Features
|
||||
|
||||
1. **SSL/TLS**: Let's Encrypt or custom certificates
|
||||
2. **Rate Limiting**: Nginx level protection
|
||||
3. **Firewall**: UFW rules for local network
|
||||
4. **Secrets**: Encrypted in `.env` file
|
||||
5. **Wallet Keys**: AES-256-GCM encrypted in database
|
||||
6. **CORS**: Configured for your domain
|
||||
7. **JWT**: Secure session tokens
|
||||
|
||||
## Monitoring
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
./deployment/health-check.sh
|
||||
|
||||
# Watch mode (continuous)
|
||||
./deployment/health-check.sh --watch
|
||||
|
||||
# Docker logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
```bash
|
||||
# Database backup
|
||||
docker-compose -f docker-compose.prod.yml exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# Environment backup
|
||||
cp .env .env.backup.$(date +%Y%m%d)
|
||||
|
||||
# Wallet keys (secure offsite storage)
|
||||
# - Solana mint authority keypair
|
||||
# - Encryption key from .env
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run setup**: `npm run setup:infra`
|
||||
2. **Get API keys**:
|
||||
- Tautulli: http://172.20.1.255:8181 → Settings → API
|
||||
- Overseer: http://172.20.1.225:5055 → Settings → General
|
||||
- Plex: https://plex.tv/claim
|
||||
3. **Edit .env** with your keys
|
||||
4. **Setup Solana**: `npm run setup:solana`
|
||||
5. **Deploy**: `npm run deploy:prod`
|
||||
6. **Configure webhooks** in Tautulli and Overseer
|
||||
7. **Test**: `npm run health`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Cannot reach local services | Check Docker network: `docker network ls` |
|
||||
| Webhook not received | Check Nginx logs: `docker/nginx/logs/access.log` |
|
||||
| Database connection failed | Verify `.env` DATABASE_URL |
|
||||
| SSL error | Check certificate paths in nginx config |
|
||||
| CORS errors | Verify CORS_ORIGINS in `.env` |
|
||||
|
||||
## Support Resources
|
||||
|
||||
- **Setup Guide**: `docs/SETUP-INFRASTRUCTURE.md`
|
||||
- **Architecture**: `docs/INFRASTRUCTURE.md`
|
||||
- **Health Check**: `npm run health`
|
||||
- **Logs**: `npm run docker:prod:logs`
|
||||
@@ -0,0 +1,81 @@
|
||||
# CoopCredits ($COOP) Media Rewards Ecosystem
|
||||
|
||||
A complete Solana-based rewards system integrated with Plex, Tautulli, and Overseer.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ coop.hobokenchicken.com │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||
│ │ Next.js │ │ Express │ │ PostgreSQL │ │ Solana Devnet │ │
|
||||
│ │ Frontend │◄─┤ API │◄─┤ Database │◄─┤ $COOP SPL │ │
|
||||
│ │ │ │ │ │ │ │ Token │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────────┘ │
|
||||
│ ▲ ▲ │
|
||||
│ │ │ │
|
||||
│ Plex OAuth Tautulli Webhooks │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Plex Server │ │ Tautulli │ │ Overseer │ │
|
||||
│ │ (Content) │ │ (Analytics) │ │ (Requests) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone and install dependencies
|
||||
cd coop-credits
|
||||
npm install
|
||||
|
||||
# 2. Set up environment
|
||||
cp backend/.env.example backend/.env
|
||||
# Edit backend/.env with your values
|
||||
|
||||
# 3. Start with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Deploy Solana program
|
||||
cd anchor-program
|
||||
anchor deploy
|
||||
|
||||
# 5. Seed initial data
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
coop-credits/
|
||||
├── anchor-program/ # Solana Anchor program for $COOP token
|
||||
├── backend/ # Express API server
|
||||
├── frontend/ # Next.js dashboard
|
||||
├── shared/ # Shared types and utilities
|
||||
├── docker/ # Docker configurations
|
||||
├── deployment/ # Deployment scripts
|
||||
└── docker-compose.yml # Full stack orchestration
|
||||
```
|
||||
|
||||
## User Flow
|
||||
|
||||
1. **Login** → User visits coop.hobokenchicken.com, authenticates with Plex
|
||||
2. **Wallet** → Auto-created Solana wallet or connect existing
|
||||
3. **Watch** → Viewing on Plex → Tautulli triggers → $COOP minted
|
||||
4. **Earn** → Real-time balance updates on dashboard
|
||||
5. **Spend** → Request content on Overseer → $COOP deducted
|
||||
6. **History** → Full transaction history visible on dashboard
|
||||
|
||||
## Admin Features
|
||||
|
||||
- System overview and analytics
|
||||
- User management and balance adjustments
|
||||
- Minting rate controls
|
||||
- Spending controls and promotions
|
||||
- Emergency pause and recovery
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `backend/.env.example` and `frontend/.env.local.example` for full configuration.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# CoopCredits Setup Guide
|
||||
|
||||
This guide will walk you through setting up the complete CoopCredits ecosystem.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Docker & Docker Compose
|
||||
- Solana CLI (for blockchain development)
|
||||
- Anchor Framework (for Solana program deployment)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Install
|
||||
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd coop-credits
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### 2. Environment Configuration
|
||||
|
||||
```bash
|
||||
# Copy example environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your values
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_SECRET` - Random string for JWT signing
|
||||
- `PLEX_CLIENT_ID` & `PLEX_CLIENT_SECRET` - From Plex.tv
|
||||
- `TAUTULLI_API_KEY` - From Tautulli settings
|
||||
- `OVERSEER_API_KEY` - From Overseer settings
|
||||
- `SOLANA_MINT_AUTHORITY_KEYPAIR` - Will be generated in step 3
|
||||
|
||||
### 3. Solana Setup
|
||||
|
||||
```bash
|
||||
# Run the Solana setup script
|
||||
npm run setup:solana
|
||||
|
||||
# This will:
|
||||
# - Install Solana CLI if needed
|
||||
# - Create a devnet keypair
|
||||
# - Request airdrop (2 SOL)
|
||||
# - Output the private key for your .env file
|
||||
```
|
||||
|
||||
Copy the `SOLANA_MINT_AUTHORITY_KEYPAIR` value into your `.env` file.
|
||||
|
||||
### 4. Deploy Solana Program
|
||||
|
||||
```bash
|
||||
cd anchor-program
|
||||
|
||||
# Build the program
|
||||
anchor build
|
||||
|
||||
# Deploy to devnet
|
||||
anchor deploy
|
||||
|
||||
# Note the Program ID and update .env SOLANA_PROGRAM_ID
|
||||
```
|
||||
|
||||
### 5. Database Setup
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Run migrations
|
||||
cd backend
|
||||
npx prisma migrate dev
|
||||
|
||||
# Generate Prisma client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### 6. Start Development
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
npm run dev
|
||||
|
||||
# Or individually:
|
||||
npm run dev:backend # API on port 3001
|
||||
npm run dev:frontend # Next.js on port 3000
|
||||
```
|
||||
|
||||
### 7. Configure Tautulli Webhook
|
||||
|
||||
1. Open Tautulli Settings
|
||||
2. Go to Notification Agents → Add Agent → Webhook
|
||||
3. Configure:
|
||||
- Webhook URL: `http://your-server:3001/webhooks/tautulli`
|
||||
- Webhook Method: POST
|
||||
- JSON Payload: See webhook template in Admin Dashboard
|
||||
4. Enable "Notify on Watched"
|
||||
|
||||
### 8. Production Deployment
|
||||
|
||||
```bash
|
||||
# Setup SSL certificates
|
||||
mkdir -p docker/nginx/ssl
|
||||
cp your-cert.pem docker/nginx/ssl/cert.pem
|
||||
cp your-key.pem docker/nginx/ssl/key.pem
|
||||
|
||||
# Deploy
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ User Browser │────▶│ Next.js (3000) │────▶│ Express (3001) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
┌─────────────────┐ │
|
||||
│ PostgreSQL │◀──────────┘
|
||||
│ (Database) │
|
||||
└─────────────────┘
|
||||
▲
|
||||
┌─────────────────┐
|
||||
│ Solana Devnet │
|
||||
│ ($COOP Token) │
|
||||
└─────────────────┘
|
||||
▲
|
||||
┌───────────────────────┴───────────────────────┐
|
||||
│ │
|
||||
┌───────▼───────┐ ┌────────▼──────┐
|
||||
│ Tautulli │ │ Overseer │
|
||||
│ (Watch Events)│ │ (Requests) │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
## User Flow
|
||||
|
||||
1. **Login**: User authenticates with Plex OAuth
|
||||
2. **Wallet**: Auto-created Solana wallet (or connect existing)
|
||||
3. **Watch**: Viewing on Plex → Tautulli triggers webhook
|
||||
4. **Earn**: Backend validates → Mints $COOP tokens
|
||||
5. **Spend**: Request content on Overseer → Deducts $COOP
|
||||
|
||||
## Admin Features
|
||||
|
||||
Access `/admin` with an admin account to:
|
||||
- View system analytics
|
||||
- Manage users and grant bonuses
|
||||
- Configure minting rates
|
||||
- Pause/resume minting
|
||||
- Monitor transactions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
```bash
|
||||
# Reset database
|
||||
docker-compose down -v
|
||||
docker-compose up -d postgres
|
||||
npx prisma migrate dev
|
||||
```
|
||||
|
||||
### Solana Transaction Failures
|
||||
```bash
|
||||
# Check balance
|
||||
solana balance <pubkey>
|
||||
|
||||
# Request airdrop
|
||||
solana airdrop 2 <pubkey>
|
||||
```
|
||||
|
||||
### Tautulli Webhook Not Working
|
||||
1. Check webhook URL is accessible
|
||||
2. Verify `TAUTULLI_WEBHOOK_SECRET` matches
|
||||
3. Check backend logs: `docker-compose logs backend`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Private Keys**: Never commit `.env` files
|
||||
2. **JWT Secret**: Use a strong random string (32+ chars)
|
||||
3. **Encryption Key**: Use `openssl rand -base64 32`
|
||||
4. **SSL**: Always use HTTPS in production
|
||||
5. **Rate Limiting**: Nginx config includes rate limits
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check logs: `docker-compose logs -f`
|
||||
2. Review environment variables
|
||||
3. Verify all services are running: `docker-compose ps`
|
||||
@@ -0,0 +1,27 @@
|
||||
[features]
|
||||
seeds = false
|
||||
skip-lint = false
|
||||
|
||||
[programs.localnet]
|
||||
coop_credits = "CoopCreDits1111111111111111111111111111111"
|
||||
|
||||
[programs.devnet]
|
||||
coop_credits = "CoopCreDits1111111111111111111111111111111"
|
||||
|
||||
[registry]
|
||||
url = "https://api.apr.dev"
|
||||
|
||||
[provider]
|
||||
cluster = "devnet"
|
||||
wallet = "~/.config/solana/id.json"
|
||||
|
||||
[scripts]
|
||||
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
|
||||
|
||||
[test]
|
||||
startup_wait = 10000
|
||||
|
||||
[test.validator]
|
||||
bind_address = "0.0.0.0"
|
||||
url = "https://api.devnet.solana.com"
|
||||
ledger = ".anchor/test-ledger"
|
||||
Generated
+3596
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"programs/*"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
overflow-checks = true
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
[profile.release.build-override]
|
||||
opt-level = 3
|
||||
incremental = false
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "coop-credits-anchor",
|
||||
"version": "1.0.0",
|
||||
"description": "CoopCredits SPL Token Program",
|
||||
"scripts": {
|
||||
"lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
|
||||
"lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check",
|
||||
"build": "anchor build",
|
||||
"deploy": "anchor deploy",
|
||||
"test": "anchor test",
|
||||
"idl:export": "anchor idl init --filepath target/idl/coop_credits.json CoopCreDits1111111111111111111111111111111"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coral-xyz/anchor": "^0.29.0",
|
||||
"@solana/spl-token": "^0.3.9",
|
||||
"@solana/web3.js": "^1.87.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bn.js": "^5.1.0",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"chai": "^4.3.4",
|
||||
"mocha": "^9.0.3",
|
||||
"prettier": "^2.6.2",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "coop-credits"
|
||||
version = "1.0.0"
|
||||
description = "CoopCredits SPL Token Program"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "coop_credits"
|
||||
|
||||
[features]
|
||||
no-entrypoint = []
|
||||
no-idl = []
|
||||
no-log-ix-name = []
|
||||
cpi = ["no-entrypoint"]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }
|
||||
anchor-spl = { version = "0.29.0", features = ["metadata"] }
|
||||
solana-program = "=1.16.20"
|
||||
@@ -0,0 +1,251 @@
|
||||
use anchor_lang::prelude::*;
|
||||
use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount};
|
||||
|
||||
declare_id!("CoopCredits111111111111111111111111111111111");
|
||||
|
||||
pub const DECIMALS: u8 = 6;
|
||||
|
||||
#[program]
|
||||
pub mod coop_credits {
|
||||
use super::*;
|
||||
|
||||
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
|
||||
let state = &mut ctx.accounts.global_state;
|
||||
state.authority = ctx.accounts.authority.key();
|
||||
state.mint = ctx.accounts.mint.key();
|
||||
state.total_minted = 0;
|
||||
state.total_burned = 0;
|
||||
state.paused = false;
|
||||
state.rate = 10;
|
||||
state.bump = ctx.bumps.global_state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mint_to_user(
|
||||
ctx: Context<MintToUser>,
|
||||
amount: u64,
|
||||
session_id: String,
|
||||
title: String,
|
||||
minutes: u32,
|
||||
) -> Result<()> {
|
||||
require!(!ctx.accounts.state.paused, ErrorCode::Paused);
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
|
||||
let seeds = &[b"mint_auth", &[ctx.bumps.mint_auth]];
|
||||
let signer = &[&seeds[..]];
|
||||
|
||||
let cpi = MintTo {
|
||||
mint: ctx.accounts.mint.to_account_info(),
|
||||
to: ctx.accounts.user_token.to_account_info(),
|
||||
authority: ctx.accounts.mint_auth.to_account_info(),
|
||||
};
|
||||
|
||||
token::mint_to(
|
||||
CpiContext::new_with_signer(ctx.accounts.token_prog.to_account_info(), cpi, signer),
|
||||
amount,
|
||||
)?;
|
||||
|
||||
let state = &mut ctx.accounts.state;
|
||||
state.total_minted = state.total_minted.saturating_add(amount);
|
||||
|
||||
let tx = &mut ctx.accounts.tx_record;
|
||||
tx.user = ctx.accounts.user_token.owner;
|
||||
tx.tx_type = TxType::Earn;
|
||||
tx.amount = amount;
|
||||
tx.timestamp = Clock::get()?.unix_timestamp;
|
||||
tx.session_id = session_id;
|
||||
tx.title = title;
|
||||
tx.minutes = minutes;
|
||||
|
||||
msg!("Minted {}", amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn burn_from_user(
|
||||
ctx: Context<BurnFromUser>,
|
||||
amount: u64,
|
||||
request_id: String,
|
||||
title: String,
|
||||
) -> Result<()> {
|
||||
require!(!ctx.accounts.state.paused, ErrorCode::Paused);
|
||||
|
||||
let cpi = Burn {
|
||||
mint: ctx.accounts.mint.to_account_info(),
|
||||
from: ctx.accounts.user_token.to_account_info(),
|
||||
authority: ctx.accounts.user.to_account_info(),
|
||||
};
|
||||
|
||||
token::burn(
|
||||
CpiContext::new(ctx.accounts.token_prog.to_account_info(), cpi),
|
||||
amount,
|
||||
)?;
|
||||
|
||||
let state = &mut ctx.accounts.state;
|
||||
state.total_burned = state.total_burned.saturating_add(amount);
|
||||
|
||||
let tx = &mut ctx.accounts.tx_record;
|
||||
tx.user = ctx.accounts.user.key();
|
||||
tx.tx_type = TxType::Spend;
|
||||
tx.amount = amount;
|
||||
tx.timestamp = Clock::get()?.unix_timestamp;
|
||||
tx.session_id = request_id;
|
||||
tx.title = title;
|
||||
tx.minutes = 0;
|
||||
|
||||
msg!("Burned {}", amount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_rate(ctx: Context<Admin>, new_rate: u32) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.rate = new_rate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pause(ctx: Context<Admin>) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.paused = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resume(ctx: Context<Admin>) -> Result<()> {
|
||||
require!(
|
||||
ctx.accounts.authority.key() == ctx.accounts.state.authority,
|
||||
ErrorCode::Unauthorized
|
||||
);
|
||||
ctx.accounts.state.paused = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct Initialize<'info> {
|
||||
#[account(init, payer = authority, space = 8 + State::SIZE, seeds = [b"state"], bump)]
|
||||
pub global_state: Account<'info, State>,
|
||||
|
||||
#[account(init, payer = authority, mint::decimals = DECIMALS, mint::authority = mint_auth)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
/// CHECK: PDA
|
||||
#[account(seeds = [b"mint_auth"], bump)]
|
||||
pub mint_auth: UncheckedAccount<'info>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
pub rent: Sysvar<'info, Rent>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
#[instruction(amount: u64, session_id: String, title: String, minutes: u32)]
|
||||
pub struct MintToUser<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
|
||||
#[account(mut)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
/// CHECK: PDA
|
||||
#[account(seeds = [b"mint_auth"], bump)]
|
||||
pub mint_auth: UncheckedAccount<'info>,
|
||||
|
||||
#[account(mut)]
|
||||
pub user_token: Account<'info, TokenAccount>,
|
||||
|
||||
#[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user_token.owner.as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)]
|
||||
pub tx_record: Account<'info, TxRecord>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
#[instruction(amount: u64, request_id: String, title: String)]
|
||||
pub struct BurnFromUser<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
|
||||
#[account(mut)]
|
||||
pub mint: Account<'info, Mint>,
|
||||
|
||||
#[account(mut)]
|
||||
pub user_token: Account<'info, TokenAccount>,
|
||||
|
||||
#[account(init, payer = authority, space = 8 + TxRecord::SIZE, seeds = [b"tx", user.key().as_ref(), &Clock::get()?.unix_timestamp.to_le_bytes()], bump)]
|
||||
pub tx_record: Account<'info, TxRecord>,
|
||||
|
||||
#[account(mut)]
|
||||
pub authority: Signer<'info>,
|
||||
|
||||
/// CHECK: User signer
|
||||
#[account(mut)]
|
||||
pub user: Signer<'info>,
|
||||
|
||||
pub system_program: Program<'info, System>,
|
||||
pub token_prog: Program<'info, Token>,
|
||||
}
|
||||
|
||||
#[derive(Accounts)]
|
||||
pub struct Admin<'info> {
|
||||
#[account(mut, seeds = [b"state"], bump = state.bump)]
|
||||
pub state: Account<'info, State>,
|
||||
pub authority: Signer<'info>,
|
||||
}
|
||||
|
||||
#[account]
|
||||
pub struct State {
|
||||
pub authority: Pubkey,
|
||||
pub mint: Pubkey,
|
||||
pub total_minted: u64,
|
||||
pub total_burned: u64,
|
||||
pub paused: bool,
|
||||
pub rate: u32,
|
||||
pub bump: u8,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub const SIZE: usize = 32 + 32 + 8 + 8 + 1 + 4 + 1;
|
||||
}
|
||||
|
||||
#[account]
|
||||
pub struct TxRecord {
|
||||
pub user: Pubkey,
|
||||
pub tx_type: TxType,
|
||||
pub amount: u64,
|
||||
pub timestamp: i64,
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub minutes: u32,
|
||||
}
|
||||
|
||||
impl TxRecord {
|
||||
pub const SIZE: usize = 32 + 1 + 8 + 8 + 68 + 132 + 4;
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq)]
|
||||
pub enum TxType {
|
||||
Earn,
|
||||
Spend,
|
||||
}
|
||||
|
||||
#[error_code]
|
||||
pub enum ErrorCode {
|
||||
#[msg("Paused")]
|
||||
Paused,
|
||||
#[msg("Unauthorized")]
|
||||
Unauthorized,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as anchor from "@coral-xyz/anchor";
|
||||
import { Program } from "@coral-xyz/anchor";
|
||||
import { CoopCredits } from "../target/types/coop_credits";
|
||||
import { expect } from "chai";
|
||||
|
||||
describe("coop-credits", () => {
|
||||
// Configure the client to use the local cluster.
|
||||
anchor.setProvider(anchor.AnchorProvider.env());
|
||||
|
||||
const program = anchor.workspace.CoopCredits as Program<CoopCredits>;
|
||||
const provider = anchor.getProvider();
|
||||
|
||||
let globalStatePDA: anchor.web3.PublicKey;
|
||||
let mintPDA: anchor.web3.PublicKey;
|
||||
let mintAuthorityPDA: anchor.web3.PublicKey;
|
||||
|
||||
before(async () => {
|
||||
// Find PDAs
|
||||
[globalStatePDA] = anchor.web3.PublicKey.findProgramAddressSync(
|
||||
[Buffer.from("global_state")],
|
||||
program.programId,
|
||||
);
|
||||
|
||||
[mintAuthorityPDA] = anchor.web3.PublicKey.findProgramAddressSync(
|
||||
[Buffer.from("mint_authority")],
|
||||
program.programId,
|
||||
);
|
||||
});
|
||||
|
||||
it("Initializes the program", async () => {
|
||||
const tx = await program.methods
|
||||
.initialize()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
systemProgram: anchor.web3.SystemProgram.programId,
|
||||
tokenProgram: anchor.utils.token.TOKEN_PROGRAM_ID,
|
||||
rent: anchor.web3.SYSVAR_RENT_PUBKEY,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
console.log("Initialize transaction signature", tx);
|
||||
|
||||
// Verify global state
|
||||
const globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.authority.toString()).to.equal(
|
||||
provider.publicKey.toString(),
|
||||
);
|
||||
expect(globalState.paused).to.be.false;
|
||||
expect(globalState.creditsPerMinute).to.equal(10);
|
||||
});
|
||||
|
||||
it("Updates minting rate", async () => {
|
||||
const newRate = 20;
|
||||
|
||||
const tx = await program.methods
|
||||
.updateMintingRate(newRate)
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
console.log("Update rate transaction signature", tx);
|
||||
|
||||
const globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.creditsPerMinute).to.equal(newRate);
|
||||
});
|
||||
|
||||
it("Pauses and resumes minting", async () => {
|
||||
// Pause
|
||||
let tx = await program.methods
|
||||
.pauseMinting()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
let globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.paused).to.be.true;
|
||||
|
||||
// Resume
|
||||
tx = await program.methods
|
||||
.resumeMinting()
|
||||
.accounts({
|
||||
globalState: globalStatePDA,
|
||||
authority: provider.publicKey,
|
||||
})
|
||||
.rpc();
|
||||
|
||||
globalState = await program.account.globalState.fetch(globalStatePDA);
|
||||
expect(globalState.paused).to.be.false;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://coop:coop_password@localhost:5432/coop_credits?schema=public"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Plex OAuth
|
||||
PLEX_CLIENT_ID=your-plex-client-id
|
||||
PLEX_CLIENT_SECRET=your-plex-client-secret
|
||||
PLEX_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# Tautulli
|
||||
TAUTULLI_URL=http://localhost:8181
|
||||
TAUTULLI_API_KEY=your-tautulli-api-key
|
||||
TAUTULLI_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Overseer
|
||||
OVERSEER_URL=http://localhost:5055
|
||||
OVERSEER_API_KEY=your-overseer-api-key
|
||||
|
||||
# Solana
|
||||
SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
SOLANA_PROGRAM_ID=CoopCredits111111111111111111111111111111111
|
||||
SOLANA_MINT_AUTHORITY_KEYPAIR=your-base58-encoded-keypair-for-minting
|
||||
SOLANA_TOKEN_DECIMALS=6
|
||||
|
||||
# Redis (optional - for caching and pub/sub)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Encryption (for wallet private keys)
|
||||
ENCRYPTION_KEY=your-32-char-encryption-key!!
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start application
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "coop-credits-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "CoopCredits API Server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "tsx src/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@solana/spl-token": "^0.3.9",
|
||||
"@solana/web3.js": "^1.87.6",
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"socket.io": "^4.7.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"prisma": "^5.7.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
plexId String @unique @map("plex_id")
|
||||
plexUsername String @map("plex_username")
|
||||
email String?
|
||||
isAdmin Boolean @default(false) @map("is_admin")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Solana wallet
|
||||
walletAddress String? @unique @map("wallet_address")
|
||||
encryptedPrivateKey String? @map("encrypted_private_key")
|
||||
|
||||
// Stats
|
||||
totalEarned Int @default(0) @map("total_earned")
|
||||
totalSpent Int @default(0) @map("total_spent")
|
||||
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
||||
|
||||
// Relations
|
||||
transactions Transaction[]
|
||||
watchEvents WatchEvent[]
|
||||
contentRequests ContentRequest[]
|
||||
sessions Session[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([plexId])
|
||||
@@index([walletAddress])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
type TransactionType
|
||||
amount Int
|
||||
|
||||
// For earned transactions
|
||||
watchEventId String? @map("watch_event_id")
|
||||
watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id])
|
||||
|
||||
// For spent transactions
|
||||
requestId String? @map("request_id")
|
||||
request ContentRequest? @relation(fields: [requestId], references: [id])
|
||||
|
||||
// Solana transaction reference
|
||||
solanaSignature String? @map("solana_signature")
|
||||
|
||||
// Metadata
|
||||
description String?
|
||||
contentTitle String? @map("content_title")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([type])
|
||||
@@index([createdAt])
|
||||
@@map("transactions")
|
||||
}
|
||||
|
||||
model WatchEvent {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
// Tautulli data
|
||||
sessionId String @map("session_id")
|
||||
ratingKey String @map("rating_key")
|
||||
contentType String @map("content_type") // movie, episode
|
||||
title String
|
||||
grandparentTitle String? @map("grandparent_title") // Show name for episodes
|
||||
|
||||
// Watch stats
|
||||
duration Int // seconds watched
|
||||
percentComplete Int @map("percent_complete")
|
||||
|
||||
// Credits earned
|
||||
creditsEarned Int @map("credits_earned")
|
||||
isProcessed Boolean @default(false) @map("is_processed")
|
||||
|
||||
// Relations
|
||||
transactions Transaction[]
|
||||
|
||||
watchedAt DateTime @map("watched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([sessionId])
|
||||
@@index([userId])
|
||||
@@index([watchedAt])
|
||||
@@map("watch_events")
|
||||
}
|
||||
|
||||
model ContentRequest {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
// Overseer data
|
||||
overseerRequestId Int @map("overseer_request_id")
|
||||
|
||||
// Content info
|
||||
mediaType String @map("media_type") // movie, tv
|
||||
tmdbId Int @map("tmdb_id")
|
||||
title String
|
||||
|
||||
// Cost
|
||||
creditsCost Int @map("credits_cost")
|
||||
|
||||
// Status
|
||||
status RequestStatus @default(PENDING)
|
||||
|
||||
// Relations
|
||||
transaction Transaction?
|
||||
|
||||
requestedAt DateTime @map("requested_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@map("content_requests")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([token])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model SystemSettings {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Minting settings
|
||||
creditsPerMinute Int @default(10) @map("credits_per_minute")
|
||||
minWatchPercent Int @default(80) @map("min_watch_percent")
|
||||
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
||||
|
||||
// Spending settings
|
||||
movieRequestCost Int @default(100) @map("movie_request_cost")
|
||||
tvRequestCost Int @default(200) @map("tv_request_cost")
|
||||
tvPerSeasonCost Int @default(50) @map("tv_per_season_cost")
|
||||
|
||||
// Multipliers
|
||||
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
||||
bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active")
|
||||
bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2)
|
||||
|
||||
// System state
|
||||
mintingPaused Boolean @default(false) @map("minting_paused")
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
@@map("system_settings")
|
||||
}
|
||||
|
||||
enum TransactionType {
|
||||
EARN
|
||||
SPEND
|
||||
BONUS
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
DECLINED
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
import { prisma } from './utils/prisma';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { authRouter } from './routes/auth';
|
||||
import { userRouter } from './routes/users';
|
||||
import { walletRouter } from './routes/wallet';
|
||||
import { transactionsRouter } from './routes/transactions';
|
||||
import { adminRouter } from './routes/admin';
|
||||
import { tautulliRouter } from './routes/tautulli';
|
||||
import { overseerRouter } from './routes/overseer';
|
||||
import { webhookRouter } from './routes/webhooks';
|
||||
import { setupSocketHandlers } from './services/socket';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Logging
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/users', userRouter);
|
||||
app.use('/api/wallet', walletRouter);
|
||||
app.use('/api/transactions', transactionsRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/tautulli', tautulliRouter);
|
||||
app.use('/api/overseer', overseerRouter);
|
||||
app.use('/webhooks', webhookRouter);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Setup WebSocket handlers
|
||||
setupSocketHandlers(io);
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
// Connect to database
|
||||
await prisma.$connect();
|
||||
console.log('✅ Connected to database');
|
||||
|
||||
// Ensure default settings exist
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
if (!settings) {
|
||||
await prisma.systemSettings.create({
|
||||
data: {
|
||||
id: 'default'
|
||||
}
|
||||
});
|
||||
console.log('✅ Created default system settings');
|
||||
}
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 API URL: ${process.env.API_URL || `http://localhost:${PORT}`}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down gracefully');
|
||||
await prisma.$disconnect();
|
||||
httpServer.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down gracefully');
|
||||
await prisma.$disconnect();
|
||||
httpServer.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
startServer();
|
||||
|
||||
export { io };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../utils/prisma';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
plexId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const authenticate = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, plexId: true, isAdmin: true, isActive: true }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
isAdmin: user.isAdmin
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
};
|
||||
|
||||
export const requireAdmin = (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.user?.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export interface ApiError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export const errorHandler = (
|
||||
err: ApiError,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
const statusCode = err.statusCode || 500;
|
||||
const message = err.message || 'Internal Server Error';
|
||||
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message,
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const asyncHandler = (fn: Function) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { mintTokens } from '../services/solana';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get system settings
|
||||
router.get('/settings',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.status(404).json({ error: 'Settings not found' });
|
||||
}
|
||||
|
||||
res.json(settings);
|
||||
})
|
||||
);
|
||||
|
||||
// Update system settings
|
||||
router.put('/settings',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const {
|
||||
creditsPerMinute,
|
||||
minWatchPercent,
|
||||
minWatchMinutes,
|
||||
movieRequestCost,
|
||||
tvRequestCost,
|
||||
newReleaseMultiplier,
|
||||
bonusMultiplierActive,
|
||||
bonusMultiplier
|
||||
} = req.body;
|
||||
|
||||
const settings = await prisma.systemSettings.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
creditsPerMinute,
|
||||
minWatchPercent,
|
||||
minWatchMinutes,
|
||||
movieRequestCost,
|
||||
tvRequestCost,
|
||||
newReleaseMultiplier,
|
||||
bonusMultiplierActive,
|
||||
bonusMultiplier,
|
||||
updatedBy: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json(settings);
|
||||
})
|
||||
);
|
||||
|
||||
// Pause minting
|
||||
router.post('/pause',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
await prisma.systemSettings.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
mintingPaused: true,
|
||||
updatedBy: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Minting paused' });
|
||||
})
|
||||
);
|
||||
|
||||
// Resume minting
|
||||
router.post('/resume',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
await prisma.systemSettings.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
mintingPaused: false,
|
||||
updatedBy: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Minting resumed' });
|
||||
})
|
||||
);
|
||||
|
||||
// Get all users
|
||||
router.get('/users',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '50', search } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 100);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ plexUsername: { contains: search as string, mode: 'insensitive' } },
|
||||
{ email: { contains: search as string, mode: 'insensitive' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true,
|
||||
watchTimeMinutes: true,
|
||||
createdAt: true
|
||||
}
|
||||
}),
|
||||
prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
users,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Update user
|
||||
router.put('/users/:id',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { isAdmin, isActive } = req.body;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
isAdmin,
|
||||
isActive
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
// Grant bonus credits
|
||||
router.post('/users/:id/bonus',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { amount, reason } = req.body;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return res.status(400).json({ error: 'Valid amount required' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (!user.walletAddress) {
|
||||
return res.status(400).json({ error: 'User has no wallet' });
|
||||
}
|
||||
|
||||
// Mint bonus tokens
|
||||
const signature = await mintTokens(
|
||||
user.walletAddress,
|
||||
amount,
|
||||
{
|
||||
sessionId: `BONUS-${Date.now()}`,
|
||||
contentTitle: reason || 'Admin Bonus',
|
||||
watchDurationMinutes: 0
|
||||
}
|
||||
);
|
||||
|
||||
if (!signature) {
|
||||
return res.status(500).json({ error: 'Failed to mint bonus' });
|
||||
}
|
||||
|
||||
// Create transaction record
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'BONUS',
|
||||
amount,
|
||||
solanaSignature: signature,
|
||||
description: reason || 'Admin bonus',
|
||||
contentTitle: reason || 'Admin Bonus'
|
||||
}
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: amount }
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
amount,
|
||||
solanaSignature: signature,
|
||||
transaction
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get dashboard analytics
|
||||
router.get('/analytics',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
userStats,
|
||||
transactionStats,
|
||||
watchStats,
|
||||
dailyActivity
|
||||
] = await Promise.all([
|
||||
// User stats
|
||||
prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN wallet_address IS NOT NULL THEN 1 END) as with_wallet,
|
||||
COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week
|
||||
FROM users
|
||||
`,
|
||||
|
||||
// Transaction stats
|
||||
prisma.$queryRaw`
|
||||
SELECT
|
||||
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned,
|
||||
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent,
|
||||
COUNT(*) as total_transactions
|
||||
FROM transactions
|
||||
`,
|
||||
|
||||
// Watch stats
|
||||
prisma.$queryRaw`
|
||||
SELECT
|
||||
SUM(duration) as total_seconds,
|
||||
SUM(credits_earned) as total_credits,
|
||||
COUNT(*) as total_events
|
||||
FROM watch_events
|
||||
WHERE is_processed = true
|
||||
`,
|
||||
|
||||
// Daily activity (last 30 days)
|
||||
prisma.$queryRaw`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(DISTINCT user_id) as active_users,
|
||||
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned,
|
||||
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent
|
||||
FROM transactions
|
||||
WHERE created_at >= ${thirtyDaysAgo}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
LIMIT 30
|
||||
`
|
||||
]);
|
||||
|
||||
res.json({
|
||||
users: userStats[0],
|
||||
transactions: transactionStats[0],
|
||||
watchStats: watchStats[0],
|
||||
dailyActivity
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as adminRouter };
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Plex OAuth configuration
|
||||
const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || '';
|
||||
const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || '';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
// Step 1: Get Plex OAuth URL
|
||||
router.get('/plex/url', asyncHandler(async (_req, res) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: PLEX_CLIENT_ID,
|
||||
redirect_uri: PLEX_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile'
|
||||
});
|
||||
|
||||
const authUrl = `https://app.plex.tv/auth#?${params.toString()}`;
|
||||
|
||||
res.json({ authUrl });
|
||||
}));
|
||||
|
||||
// Step 2: Handle Plex OAuth callback
|
||||
router.post('/plex/callback', asyncHandler(async (req, res) => {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code required' });
|
||||
}
|
||||
|
||||
// Exchange code for Plex token
|
||||
const tokenResponse = await axios.post(
|
||||
'https://plex.tv/api/v2/oauth/token',
|
||||
{
|
||||
code,
|
||||
client_id: PLEX_CLIENT_ID,
|
||||
grant_type: 'authorization_code'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Client-Identifier': PLEX_CLIENT_ID
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const plexToken = tokenResponse.data.access_token;
|
||||
|
||||
// Get user info from Plex
|
||||
const userResponse = await axios.get('https://plex.tv/api/v2/user', {
|
||||
headers: {
|
||||
'X-Plex-Token': plexToken,
|
||||
'X-Plex-Client-Identifier': PLEX_CLIENT_ID,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const plexUser = userResponse.data;
|
||||
|
||||
// Check if user exists, create if not
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { plexId: plexUser.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
plexId: plexUser.id,
|
||||
plexUsername: plexUser.username || plexUser.email,
|
||||
email: plexUser.email,
|
||||
// Check if user is Plex admin (implement your logic)
|
||||
isAdmin: false
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Update user info
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
plexUsername: plexUser.username || plexUser.email,
|
||||
email: plexUser.email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
isAdmin: user.isAdmin
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
sessionToken: session.token,
|
||||
user: {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
plexUsername: user.plexUsername,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
walletAddress: user.walletAddress,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify token
|
||||
router.get('/verify', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
plexUsername: user.plexUsername,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
walletAddress: user.walletAddress,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}));
|
||||
|
||||
// Logout
|
||||
router.post('/logout', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId: decoded.userId }
|
||||
});
|
||||
} catch {
|
||||
// Invalid token, ignore
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
export { router as authRouter };
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { getTokenBalance } from '../services/solana';
|
||||
import { io } from '../index';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OVERSEER_URL = process.env.OVERSEER_URL || '';
|
||||
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || '';
|
||||
|
||||
const overseerClient = axios.create({
|
||||
baseURL: `${OVERSEER_URL}/api/v1`,
|
||||
headers: {
|
||||
'X-Api-Key': OVERSEER_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
// Get request costs
|
||||
router.get('/costs',
|
||||
authenticate,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
res.json({
|
||||
movie: settings?.movieRequestCost || 100,
|
||||
tv: settings?.tvRequestCost || 200,
|
||||
tvPerSeason: settings?.tvPerSeasonCost || 50
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's balance and request availability
|
||||
router.get('/balance',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.json({
|
||||
hasWallet: false,
|
||||
balance: 0,
|
||||
canRequest: false
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
balance,
|
||||
canRequestMovie: balance >= (settings?.movieRequestCost || 100),
|
||||
canRequestTV: balance >= (settings?.tvRequestCost || 200),
|
||||
costs: {
|
||||
movie: settings?.movieRequestCost || 100,
|
||||
tv: settings?.tvRequestCost || 200
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Search for content
|
||||
router.get('/search',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { query } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query required' });
|
||||
}
|
||||
|
||||
const response = await overseerClient.get('/search', {
|
||||
params: { query }
|
||||
});
|
||||
|
||||
res.json(response.data);
|
||||
})
|
||||
);
|
||||
|
||||
// Request content
|
||||
router.post('/request',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { mediaType, mediaId, title, seasons } = req.body;
|
||||
|
||||
if (!mediaType || !mediaId || !title) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.status(400).json({ error: 'Wallet required' });
|
||||
}
|
||||
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
// Calculate cost
|
||||
let cost = 0;
|
||||
if (mediaType === 'movie') {
|
||||
cost = settings?.movieRequestCost || 100;
|
||||
} else if (mediaType === 'tv') {
|
||||
cost = settings?.tvRequestCost || 200;
|
||||
if (seasons && seasons.length > 1) {
|
||||
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Check balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
if (balance < cost) {
|
||||
return res.status(400).json({
|
||||
error: 'Insufficient balance',
|
||||
required: cost,
|
||||
current: balance
|
||||
});
|
||||
}
|
||||
|
||||
// Create request in Overseer
|
||||
const overseerRequest = await overseerClient.post('/request', {
|
||||
mediaType,
|
||||
mediaId,
|
||||
...(seasons && { seasons })
|
||||
});
|
||||
|
||||
// Create local request record
|
||||
const request = await prisma.contentRequest.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
overseerRequestId: overseerRequest.data.id,
|
||||
mediaType,
|
||||
tmdbId: mediaId,
|
||||
title,
|
||||
creditsCost: cost,
|
||||
status: 'PENDING',
|
||||
requestedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
request,
|
||||
cost,
|
||||
message: 'Request submitted. Credits will be deducted when approved.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Webhook: Handle Overseer request status changes
|
||||
router.post('/webhook',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { request_id, status } = req.body;
|
||||
|
||||
if (!request_id || !status) {
|
||||
return res.status(400).json({ error: 'Missing fields' });
|
||||
}
|
||||
|
||||
// Find local request
|
||||
const request = await prisma.contentRequest.findFirst({
|
||||
where: { overseerRequestId: request_id },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
// Update status
|
||||
const updatedRequest = await prisma.contentRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
// If approved, deduct credits
|
||||
if (status === 'APPROVED' && !request.user.totalSpent) {
|
||||
// Note: Actual burning would happen here
|
||||
// For now, we just record the transaction
|
||||
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: request.userId,
|
||||
type: 'SPEND',
|
||||
amount: request.creditsCost,
|
||||
requestId: request.id,
|
||||
description: `Request: ${request.title}`,
|
||||
contentTitle: request.title
|
||||
}
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
totalSpent: { increment: request.creditsCost }
|
||||
}
|
||||
});
|
||||
|
||||
// Emit update
|
||||
io.to(`user:${request.userId}`).emit('credits_spent', {
|
||||
amount: request.creditsCost,
|
||||
title: request.title,
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, request: updatedRequest });
|
||||
})
|
||||
);
|
||||
|
||||
export { router as overseerRouter };
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TAUTULLI_URL = process.env.TAUTULLI_URL || '';
|
||||
const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || '';
|
||||
|
||||
// Get Tautulli connection status
|
||||
router.get('/status',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||
params: {
|
||||
apikey: TAUTULLI_API_KEY,
|
||||
cmd: 'get_server_info'
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
connected: true,
|
||||
data: response.data.response.data
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
connected: false,
|
||||
error: 'Failed to connect to Tautulli'
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Get watch statistics
|
||||
router.get('/stats',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||
params: {
|
||||
apikey: TAUTULLI_API_KEY,
|
||||
cmd: 'get_libraries'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(response.data.response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Get webhook configuration guide
|
||||
router.get('/webhook-config',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const webhookUrl = `${process.env.API_URL}/webhooks/tautulli`;
|
||||
|
||||
res.json({
|
||||
webhookUrl,
|
||||
instructions: [
|
||||
'1. Open Tautulli Settings',
|
||||
'2. Go to Notification Agents',
|
||||
'3. Add Webhook',
|
||||
'4. Set Webhook URL to the URL above',
|
||||
'5. Set Webhook Method to POST',
|
||||
'6. Configure triggers for "Watched" events',
|
||||
'7. Set payload to JSON format'
|
||||
],
|
||||
payloadTemplate: {
|
||||
action: 'watched',
|
||||
user_id: '{user_id}',
|
||||
username: '{username}',
|
||||
rating_key: '{rating_key}',
|
||||
session_key: '{session_key}',
|
||||
media_type: '{media_type}',
|
||||
title: '{title}',
|
||||
grandparent_title: '{grandparent_title}',
|
||||
started: '{started}',
|
||||
stopped: '{stopped}',
|
||||
percent_complete: '{percent_complete}',
|
||||
is_new: '{is_new}'
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as tautulliRouter };
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get user's transactions
|
||||
router.get('/',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20', type } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 100);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = { userId: req.user!.id };
|
||||
if (type) {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
include: {
|
||||
watchEvent: {
|
||||
select: {
|
||||
duration: true,
|
||||
percentComplete: true
|
||||
}
|
||||
},
|
||||
request: {
|
||||
select: {
|
||||
mediaType: true,
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.transaction.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
transactions,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get transaction stats
|
||||
router.get('/stats',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalStats,
|
||||
recentStats,
|
||||
byType
|
||||
] = await Promise.all([
|
||||
// All time stats
|
||||
prisma.transaction.groupBy({
|
||||
by: ['type'],
|
||||
where: { userId: req.user!.id },
|
||||
_sum: { amount: true },
|
||||
_count: { id: true }
|
||||
}),
|
||||
// Last 30 days
|
||||
prisma.transaction.groupBy({
|
||||
by: ['type'],
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
createdAt: { gte: thirtyDaysAgo }
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: { id: true }
|
||||
}),
|
||||
// By type breakdown
|
||||
prisma.transaction.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
select: {
|
||||
type: true,
|
||||
amount: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
})
|
||||
]);
|
||||
|
||||
// Calculate daily earnings for chart
|
||||
const dailyEarnings = await prisma.$queryRaw`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned,
|
||||
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent
|
||||
FROM transactions
|
||||
WHERE user_id = ${req.user!.id}
|
||||
AND created_at >= ${thirtyDaysAgo}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`;
|
||||
|
||||
res.json({
|
||||
total: totalStats,
|
||||
recent: recentStats,
|
||||
dailyEarnings,
|
||||
recentTransactions: byType.slice(0, 10)
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get all transactions
|
||||
router.get('/admin/all',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '50', userId, type } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 100);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
if (userId) where.userId = userId;
|
||||
if (type) where.type = type;
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
walletAddress: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.transaction.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
transactions,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get system-wide stats
|
||||
router.get('/admin/stats',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const [
|
||||
totalMinted,
|
||||
totalBurned,
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
recentTransactions
|
||||
] = await Promise.all([
|
||||
prisma.transaction.aggregate({
|
||||
where: { type: 'EARN' },
|
||||
_sum: { amount: true }
|
||||
}),
|
||||
prisma.transaction.aggregate({
|
||||
where: { type: 'SPEND' },
|
||||
_sum: { amount: true }
|
||||
}),
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { walletAddress: { not: null } } }),
|
||||
prisma.transaction.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
totalMinted: totalMinted._sum.amount || 0,
|
||||
totalBurned: totalBurned._sum.amount || 0,
|
||||
netSupply: (totalMinted._sum.amount || 0) - (totalBurned._sum.amount || 0),
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
recentTransactions
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as transactionsRouter };
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get current user profile
|
||||
router.get('/me',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
isAdmin: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true,
|
||||
watchTimeMinutes: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
// Update user profile
|
||||
router.put('/me',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: { email },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
walletAddress: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's watch history
|
||||
router.get('/me/watch-history',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20' } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 50);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.watchEvent.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { watchedAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
select: {
|
||||
id: true,
|
||||
contentType: true,
|
||||
title: true,
|
||||
grandparentTitle: true,
|
||||
duration: true,
|
||||
percentComplete: true,
|
||||
creditsEarned: true,
|
||||
isProcessed: true,
|
||||
watchedAt: true
|
||||
}
|
||||
}),
|
||||
prisma.watchEvent.count({ where: { userId: req.user!.id } })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
events,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's content requests
|
||||
router.get('/me/requests',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20', status } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 50);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = { userId: req.user!.id };
|
||||
if (status) where.status = status;
|
||||
|
||||
const [requests, total] = await Promise.all([
|
||||
prisma.contentRequest.findMany({
|
||||
where,
|
||||
orderBy: { requestedAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum
|
||||
}),
|
||||
prisma.contentRequest.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
requests,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as userRouter };
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { createWallet, getTokenBalance, requestAirdrop } from '../services/solana';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-32-chars-long!!!!!';
|
||||
|
||||
// Encrypt private key
|
||||
function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
// Decrypt private key
|
||||
function decrypt(encryptedData: string): string {
|
||||
const parts = encryptedData.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Get user's wallet info
|
||||
router.get('/',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.json({
|
||||
hasWallet: false,
|
||||
balance: 0,
|
||||
totalEarned: user?.totalEarned || 0,
|
||||
totalSpent: user?.totalSpent || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Get on-chain balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
address: user.walletAddress,
|
||||
balance,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent,
|
||||
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Create new wallet
|
||||
router.post('/create',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (user?.walletAddress) {
|
||||
return res.status(400).json({ error: 'Wallet already exists' });
|
||||
}
|
||||
|
||||
// Create new Solana wallet
|
||||
const wallet = createWallet();
|
||||
const encryptedKey = encrypt(wallet.secretKey);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
walletAddress: wallet.publicKey,
|
||||
encryptedPrivateKey: encryptedKey
|
||||
}
|
||||
});
|
||||
|
||||
// Request airdrop for testing
|
||||
await requestAirdrop(wallet.publicKey);
|
||||
|
||||
res.json({
|
||||
address: wallet.publicKey,
|
||||
message: 'Wallet created successfully. Funded with 2 SOL for transaction fees.',
|
||||
warning: 'Please backup your recovery phrase if shown. This is the only time it will be displayed.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Connect existing wallet
|
||||
router.post('/connect',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { address } = req.body;
|
||||
|
||||
if (!address) {
|
||||
return res.status(400).json({ error: 'Wallet address required' });
|
||||
}
|
||||
|
||||
// Check if address is already connected to another user
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
walletAddress: address,
|
||||
NOT: { id: req.user!.id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Wallet already connected to another account' });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: { walletAddress: address }
|
||||
});
|
||||
|
||||
res.json({
|
||||
address,
|
||||
message: 'Wallet connected successfully'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get recovery phrase (only shown once at creation)
|
||||
router.post('/backup',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.encryptedPrivateKey) {
|
||||
return res.status(400).json({ error: 'No wallet found' });
|
||||
}
|
||||
|
||||
// Decrypt and return private key for backup
|
||||
const privateKey = decrypt(user.encryptedPrivateKey);
|
||||
|
||||
res.json({
|
||||
privateKey,
|
||||
warning: 'Store this securely. Never share it with anyone.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get user's wallet
|
||||
router.get('/admin/:userId',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.userId },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
let balance = 0;
|
||||
if (user.walletAddress) {
|
||||
balance = await getTokenBalance(user.walletAddress);
|
||||
}
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
balance
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as walletRouter };
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { mintTokens } from '../services/solana';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { io } from '../index';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || '';
|
||||
|
||||
// Verify webhook signature
|
||||
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set
|
||||
|
||||
const expected = crypto
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expected)
|
||||
);
|
||||
}
|
||||
|
||||
// Tautulli webhook endpoint
|
||||
router.post('/tautulli',
|
||||
asyncHandler(async (req, res) => {
|
||||
const signature = req.headers['x-tautulli-signature'] as string;
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
// Verify signature if configured
|
||||
if (WEBHOOK_SECRET && signature && !verifyWebhookSignature(payload, signature)) {
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
const event = req.body;
|
||||
|
||||
// Only process watched events
|
||||
if (event.action !== 'watched') {
|
||||
return res.json({ message: 'Event type not processed' });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!event.user_id || !event.rating_key || !event.session_key) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Find user by Plex ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: event.user_id.toString() }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(`User not found for Plex ID: ${event.user_id}`);
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (!user.walletAddress) {
|
||||
console.log(`User ${user.plexUsername} has no wallet`);
|
||||
return res.status(400).json({ error: 'User has no wallet' });
|
||||
}
|
||||
|
||||
// Check for duplicate events
|
||||
const existing = await prisma.watchEvent.findUnique({
|
||||
where: { sessionId: event.session_key.toString() }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.json({ message: 'Event already processed' });
|
||||
}
|
||||
|
||||
// Get system settings
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const creditsPerMinute = settings?.creditsPerMinute || 10;
|
||||
const minWatchPercent = settings?.minWatchPercent || 80;
|
||||
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
||||
|
||||
// Calculate watch duration
|
||||
const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60);
|
||||
const percentComplete = event.percent_complete || 0;
|
||||
|
||||
// Validate minimum requirements
|
||||
if (percentComplete < minWatchPercent) {
|
||||
return res.json({
|
||||
message: 'Watch percentage too low',
|
||||
percentComplete,
|
||||
required: minWatchPercent
|
||||
});
|
||||
}
|
||||
|
||||
if (watchDurationMinutes < minWatchMinutes) {
|
||||
return res.json({
|
||||
message: 'Watch duration too short',
|
||||
watchDurationMinutes,
|
||||
required: minWatchMinutes
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate credits
|
||||
let creditsEarned = watchDurationMinutes * creditsPerMinute;
|
||||
|
||||
// Apply multipliers
|
||||
if (settings?.newReleaseMultiplier && event.is_new) {
|
||||
creditsEarned = Math.floor(creditsEarned * Number(settings.newReleaseMultiplier));
|
||||
}
|
||||
|
||||
if (settings?.bonusMultiplierActive) {
|
||||
creditsEarned = Math.floor(creditsEarned * Number(settings.bonusMultiplier));
|
||||
}
|
||||
|
||||
// Create watch event record
|
||||
const watchEvent = await prisma.watchEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sessionId: event.session_key.toString(),
|
||||
ratingKey: event.rating_key.toString(),
|
||||
contentType: event.media_type,
|
||||
title: event.title,
|
||||
grandparentTitle: event.grandparent_title,
|
||||
duration: event.stopped - event.started,
|
||||
percentComplete: Math.floor(percentComplete),
|
||||
creditsEarned,
|
||||
watchedAt: new Date(event.stopped * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
// Mint tokens on Solana
|
||||
const signature = await mintTokens(
|
||||
user.walletAddress,
|
||||
creditsEarned,
|
||||
{
|
||||
sessionId: event.session_key.toString(),
|
||||
contentTitle: event.title,
|
||||
watchDurationMinutes
|
||||
}
|
||||
);
|
||||
|
||||
if (signature) {
|
||||
// Create transaction record
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'EARN',
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
solanaSignature: signature,
|
||||
description: `Watched ${event.title}`,
|
||||
contentTitle: event.title
|
||||
}
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: creditsEarned },
|
||||
watchTimeMinutes: { increment: watchDurationMinutes }
|
||||
}
|
||||
});
|
||||
|
||||
// Mark watch event as processed
|
||||
await prisma.watchEvent.update({
|
||||
where: { id: watchEvent.id },
|
||||
data: { isProcessed: true }
|
||||
});
|
||||
|
||||
// Emit real-time update via WebSocket
|
||||
io.to(`user:${user.id}`).emit('credits_earned', {
|
||||
amount: creditsEarned,
|
||||
title: event.title,
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
type: 'EARN',
|
||||
amount: creditsEarned,
|
||||
contentTitle: event.title,
|
||||
createdAt: transaction.createdAt
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
creditsEarned,
|
||||
solanaSignature: signature,
|
||||
message: `Minted ${creditsEarned} COOP for watching ${event.title}`
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to mint tokens' });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Test webhook endpoint
|
||||
router.post('/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
message: 'Webhook endpoint working',
|
||||
timestamp: new Date().toISOString(),
|
||||
body: req.body
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as webhookRouter };
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../utils/prisma';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
userId?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function setupSocketHandlers(io: Server) {
|
||||
// Authentication middleware
|
||||
io.use(async (socket: AuthenticatedSocket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth.token;
|
||||
|
||||
if (!token) {
|
||||
return next(new Error('Authentication required'));
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, isAdmin: true, isActive: true }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return next(new Error('User not found or inactive'));
|
||||
}
|
||||
|
||||
socket.userId = user.id;
|
||||
socket.isAdmin = user.isAdmin;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(new Error('Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket: AuthenticatedSocket) => {
|
||||
console.log(`Client connected: ${socket.userId}`);
|
||||
|
||||
// Join user-specific room
|
||||
if (socket.userId) {
|
||||
socket.join(`user:${socket.userId}`);
|
||||
}
|
||||
|
||||
// Join admin room if admin
|
||||
if (socket.isAdmin) {
|
||||
socket.join('admins');
|
||||
}
|
||||
|
||||
// Handle subscription to transaction updates
|
||||
socket.on('subscribe_transactions', () => {
|
||||
if (socket.userId) {
|
||||
socket.join(`transactions:${socket.userId}`);
|
||||
console.log(`User ${socket.userId} subscribed to transactions`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle unsubscription
|
||||
socket.on('unsubscribe_transactions', () => {
|
||||
if (socket.userId) {
|
||||
socket.leave(`transactions:${socket.userId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.userId}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to emit to specific user
|
||||
export function emitToUser(userId: string, event: string, data: any) {
|
||||
const { io } = require('../index');
|
||||
io.to(`user:${userId}`).emit(event, data);
|
||||
}
|
||||
|
||||
// Helper to emit to all admins
|
||||
export function emitToAdmins(event: string, data: any) {
|
||||
const { io } = require('../index');
|
||||
io.to('admins').emit(event, data);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
Connection,
|
||||
PublicKey,
|
||||
Keypair,
|
||||
Transaction,
|
||||
SystemProgram,
|
||||
sendAndConfirmTransaction
|
||||
} from '@solana/web3.js';
|
||||
import {
|
||||
getOrCreateAssociatedTokenAccount,
|
||||
createMintToInstruction,
|
||||
createBurnInstruction,
|
||||
getAccount,
|
||||
TOKEN_PROGRAM_ID,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
} from '@solana/spl-token';
|
||||
import bs58 from 'bs58';
|
||||
import { prisma } from '../utils/prisma';
|
||||
|
||||
const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com';
|
||||
const PROGRAM_ID = new PublicKey(process.env.SOLANA_PROGRAM_ID || 'CoopCredits111111111111111111111111111111111');
|
||||
const DECIMALS = parseInt(process.env.SOLANA_TOKEN_DECIMALS || '6');
|
||||
|
||||
// Backend mint authority keypair (stored securely)
|
||||
let mintAuthority: Keypair | null = null;
|
||||
|
||||
try {
|
||||
if (process.env.SOLANA_MINT_AUTHORITY_KEYPAIR) {
|
||||
const secretKey = bs58.decode(process.env.SOLANA_MINT_AUTHORITY_KEYPAIR);
|
||||
mintAuthority = Keypair.fromSecretKey(secretKey);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Mint authority not configured');
|
||||
}
|
||||
|
||||
export const connection = new Connection(RPC_URL, 'confirmed');
|
||||
|
||||
// Get token mint address from program state
|
||||
export async function getTokenMint(): Promise<PublicKey | null> {
|
||||
try {
|
||||
// In a real implementation, you'd derive this from the program state
|
||||
// For now, return from environment or stored config
|
||||
const config = await prisma.systemSettings.findFirst();
|
||||
if (config) {
|
||||
// Store/retrieve mint address from config
|
||||
return null; // Placeholder
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get token mint:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new Solana wallet for a user
|
||||
export function createWallet(): { publicKey: string; secretKey: string } {
|
||||
const keypair = Keypair.generate();
|
||||
|
||||
return {
|
||||
publicKey: keypair.publicKey.toBase58(),
|
||||
secretKey: bs58.encode(keypair.secretKey)
|
||||
};
|
||||
}
|
||||
|
||||
// Get or create token account for user
|
||||
export async function getOrCreateTokenAccount(
|
||||
userPublicKey: PublicKey,
|
||||
mint: PublicKey
|
||||
): Promise<PublicKey> {
|
||||
const tokenAccount = await getOrCreateAssociatedTokenAccount(
|
||||
connection,
|
||||
mintAuthority!, // payer
|
||||
mint,
|
||||
userPublicKey
|
||||
);
|
||||
|
||||
return tokenAccount.address;
|
||||
}
|
||||
|
||||
// Mint tokens to user (called by backend after watch event)
|
||||
export async function mintTokens(
|
||||
userWalletAddress: string,
|
||||
amount: number,
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
contentTitle: string;
|
||||
watchDurationMinutes: number;
|
||||
}
|
||||
): Promise<string | null> {
|
||||
if (!mintAuthority) {
|
||||
throw new Error('Mint authority not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
const userPublicKey = new PublicKey(userWalletAddress);
|
||||
const mint = await getTokenMint();
|
||||
|
||||
if (!mint) {
|
||||
throw new Error('Token mint not found');
|
||||
}
|
||||
|
||||
// Get or create user's token account
|
||||
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
|
||||
|
||||
// Calculate amount with decimals
|
||||
const amountWithDecimals = amount * Math.pow(10, DECIMALS);
|
||||
|
||||
// Create mint instruction
|
||||
const mintInstruction = createMintToInstruction(
|
||||
mint,
|
||||
tokenAccount,
|
||||
mintAuthority.publicKey,
|
||||
BigInt(Math.floor(amountWithDecimals))
|
||||
);
|
||||
|
||||
// Create and send transaction
|
||||
const transaction = new Transaction().add(mintInstruction);
|
||||
const signature = await sendAndConfirmTransaction(
|
||||
connection,
|
||||
transaction,
|
||||
[mintAuthority]
|
||||
);
|
||||
|
||||
console.log(`Minted ${amount} COOP to ${userWalletAddress}: ${signature}`);
|
||||
|
||||
return signature;
|
||||
} catch (error) {
|
||||
console.error('Failed to mint tokens:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Burn tokens from user (called when content request is approved)
|
||||
export async function burnTokens(
|
||||
userWalletAddress: string,
|
||||
userSecretKey: string,
|
||||
amount: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const userKeypair = Keypair.fromSecretKey(bs58.decode(userSecretKey));
|
||||
const mint = await getTokenMint();
|
||||
|
||||
if (!mint) {
|
||||
throw new Error('Token mint not found');
|
||||
}
|
||||
|
||||
const userPublicKey = new PublicKey(userWalletAddress);
|
||||
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
|
||||
|
||||
// Check balance
|
||||
const accountInfo = await getAccount(connection, tokenAccount);
|
||||
const amountWithDecimals = BigInt(Math.floor(amount * Math.pow(10, DECIMALS)));
|
||||
|
||||
if (accountInfo.amount < amountWithDecimals) {
|
||||
throw new Error('Insufficient balance');
|
||||
}
|
||||
|
||||
// Create burn instruction
|
||||
const burnInstruction = createBurnInstruction(
|
||||
tokenAccount,
|
||||
mint,
|
||||
userKeypair.publicKey,
|
||||
amountWithDecimals
|
||||
);
|
||||
|
||||
const transaction = new Transaction().add(burnInstruction);
|
||||
const signature = await sendAndConfirmTransaction(
|
||||
connection,
|
||||
transaction,
|
||||
[userKeypair]
|
||||
);
|
||||
|
||||
console.log(`Burned ${amount} COOP from ${userWalletAddress}: ${signature}`);
|
||||
|
||||
return signature;
|
||||
} catch (error) {
|
||||
console.error('Failed to burn tokens:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get token balance for user
|
||||
export async function getTokenBalance(walletAddress: string): Promise<number> {
|
||||
try {
|
||||
const mint = await getTokenMint();
|
||||
if (!mint) return 0;
|
||||
|
||||
const userPublicKey = new PublicKey(walletAddress);
|
||||
|
||||
try {
|
||||
const tokenAccount = await getOrCreateAssociatedTokenAccount(
|
||||
connection,
|
||||
mintAuthority!,
|
||||
mint,
|
||||
userPublicKey
|
||||
);
|
||||
|
||||
const accountInfo = await getAccount(connection, tokenAccount.address);
|
||||
return Number(accountInfo.amount) / Math.pow(10, DECIMALS);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get token balance:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Request airdrop for testing (devnet only)
|
||||
export async function requestAirdrop(walletAddress: string): Promise<string | null> {
|
||||
try {
|
||||
const publicKey = new PublicKey(walletAddress);
|
||||
const signature = await connection.requestAirdrop(publicKey, 2 * 1000000000); // 2 SOL
|
||||
await connection.confirmTransaction(signature);
|
||||
return signature;
|
||||
} catch (error) {
|
||||
console.error('Airdrop failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# CoopCredits Production Deployment Script
|
||||
# For infrastructure: 172.20.1.0/24
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "🚀 CoopCredits Production Deployment"
|
||||
echo "===================================="
|
||||
echo -e "${NC}"
|
||||
echo "Time: $(date)"
|
||||
echo "Project: $PROJECT_DIR"
|
||||
echo ""
|
||||
|
||||
# ==========================================
|
||||
# Pre-deployment Checks
|
||||
# ==========================================
|
||||
|
||||
pre_deployment_checks() {
|
||||
echo -e "${BLUE}Running pre-deployment checks...${NC}"
|
||||
|
||||
# Check environment file
|
||||
if [ ! -f "$PROJECT_DIR/.env" ]; then
|
||||
echo -e "${RED}❌ .env file not found!${NC}"
|
||||
echo "Run: ./deployment/setup-infrastructure.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check SSL certificates
|
||||
if [ ! -f "$PROJECT_DIR/docker/nginx/ssl/cert.pem" ] && [ ! -d "$PROJECT_DIR/docker/nginx/letsencrypt" ]; then
|
||||
echo -e "${YELLOW}⚠️ SSL certificates not found${NC}"
|
||||
echo "Place certificates in docker/nginx/ssl/ or setup Let's Encrypt"
|
||||
read -p "Continue without SSL? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}❌ Docker not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo -e "${RED}❌ Docker Compose not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required env variables
|
||||
source "$PROJECT_DIR/.env"
|
||||
|
||||
local required_vars=(
|
||||
"JWT_SECRET"
|
||||
"TAUTULLI_API_KEY"
|
||||
"OVERSEER_API_KEY"
|
||||
"SOLANA_MINT_AUTHORITY_KEYPAIR"
|
||||
"ENCRYPTION_KEY"
|
||||
)
|
||||
|
||||
local missing=0
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo -e "${RED}❌ Missing required environment variable: $var${NC}"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $missing -eq 1 ]; then
|
||||
echo "Please edit $PROJECT_DIR/.env and add the missing values"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Pre-deployment checks passed${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Backup Current State
|
||||
# ==========================================
|
||||
|
||||
backup_state() {
|
||||
echo -e "${BLUE}Creating backup...${NC}"
|
||||
|
||||
local backup_dir="$PROJECT_DIR/backups/$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
# Backup database if running
|
||||
if docker ps | grep -q "coop-postgres"; then
|
||||
echo "Backing up database..."
|
||||
docker exec coop-postgres pg_dump -U coop coop_credits > "$backup_dir/database.sql" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Backup environment
|
||||
cp "$PROJECT_DIR/.env" "$backup_dir/"
|
||||
|
||||
# Backup Docker volumes list
|
||||
docker volume ls | grep coop > "$backup_dir/volumes.txt" 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}✅ Backup created: $backup_dir${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Build and Deploy
|
||||
# ==========================================
|
||||
|
||||
deploy() {
|
||||
echo -e "${BLUE}Building and deploying...${NC}"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Pull latest images
|
||||
echo "Pulling base images..."
|
||||
docker-compose -f docker-compose.prod.yml pull
|
||||
|
||||
# Build services
|
||||
echo "Building services..."
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
# Stop existing services
|
||||
echo "Stopping existing services..."
|
||||
docker-compose -f docker-compose.prod.yml down --remove-orphans
|
||||
|
||||
# Start services
|
||||
echo "Starting services..."
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Wait for database
|
||||
echo "Waiting for database..."
|
||||
sleep 10
|
||||
|
||||
# Run migrations
|
||||
echo "Running database migrations..."
|
||||
docker-compose -f docker-compose.prod.yml exec -T backend npx prisma migrate deploy || {
|
||||
echo -e "${YELLOW}⚠️ Migration may have already been applied${NC}"
|
||||
}
|
||||
|
||||
# Seed initial data if needed
|
||||
echo "Checking for initial data..."
|
||||
docker-compose -f docker-compose.prod.yml exec -T backend npx prisma db seed 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}✅ Deployment complete!${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Post-deployment Verification
|
||||
# ==========================================
|
||||
|
||||
verify_deployment() {
|
||||
echo -e "${BLUE}Verifying deployment...${NC}"
|
||||
|
||||
local retries=30
|
||||
local delay=2
|
||||
|
||||
# Wait for services to be ready
|
||||
echo "Waiting for services to start..."
|
||||
for i in $(seq 1 $retries); do
|
||||
if curl -sf https://coop.hobokenchicken.com/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ Website is responding${NC}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $i -eq $retries ]; then
|
||||
echo -e "${RED}❌ Website failed to start${NC}"
|
||||
echo "Check logs: docker-compose -f docker-compose.prod.yml logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
sleep $delay
|
||||
done
|
||||
|
||||
# Check database connection
|
||||
if docker-compose -f docker-compose.prod.yml exec -T postgres pg_isready -U coop > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ Database is ready${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Database not responding${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Display Info
|
||||
# ==========================================
|
||||
|
||||
show_info() {
|
||||
echo -e "${BLUE}Deployment Information${NC}"
|
||||
echo "======================"
|
||||
echo ""
|
||||
echo "Website: https://coop.hobokenchicken.com"
|
||||
echo "API: https://coop.hobokenchicken.com/api"
|
||||
echo "Webhooks: https://coop.hobokenchicken.com/webhooks/"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
docker-compose -f "$PROJECT_DIR/docker-compose.prod.yml" ps
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " View logs: docker-compose -f docker-compose.prod.yml logs -f"
|
||||
echo " Stop: docker-compose -f docker-compose.prod.yml down"
|
||||
echo " Restart: docker-compose -f docker-compose.prod.yml restart"
|
||||
echo " Health check: ./deployment/health-check.sh"
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 Deployment successful!${NC}"
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Main
|
||||
# ==========================================
|
||||
|
||||
main() {
|
||||
pre_deployment_checks
|
||||
backup_state
|
||||
deploy
|
||||
verify_deployment
|
||||
show_info
|
||||
}
|
||||
|
||||
# Handle script arguments
|
||||
case "${1:-}" in
|
||||
--check)
|
||||
pre_deployment_checks
|
||||
exit 0
|
||||
;;
|
||||
--backup)
|
||||
backup_state
|
||||
exit 0
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --check Run pre-deployment checks only"
|
||||
echo " --backup Create backup only"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
main
|
||||
;;
|
||||
esac
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
|
||||
# CoopCredits Deployment Script
|
||||
# Usage: ./deploy.sh [environment]
|
||||
|
||||
set -e
|
||||
|
||||
ENVIRONMENT=${1:-production}
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "🚀 CoopCredits Deployment"
|
||||
echo "========================"
|
||||
echo "Environment: $ENVIRONMENT"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
check_prerequisites() {
|
||||
echo "Checking prerequisites..."
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "❌ Docker is required but not installed. Aborting." >&2; exit 1; }
|
||||
command -v docker-compose >/dev/null 2>&1 || { echo "❌ Docker Compose is required but not installed. Aborting." >&2; exit 1; }
|
||||
|
||||
echo "✅ Prerequisites met"
|
||||
}
|
||||
|
||||
# Load environment variables
|
||||
load_env() {
|
||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||
echo "Loading environment variables..."
|
||||
set -a
|
||||
source "$PROJECT_DIR/.env"
|
||||
set +a
|
||||
else
|
||||
echo "⚠️ .env file not found. Using default values."
|
||||
fi
|
||||
}
|
||||
|
||||
# Build and deploy
|
||||
build_and_deploy() {
|
||||
echo ""
|
||||
echo "Building and deploying..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Pull latest images
|
||||
docker-compose pull
|
||||
|
||||
# Build services
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# Wait for database
|
||||
echo "Waiting for database..."
|
||||
sleep 10
|
||||
|
||||
# Run migrations
|
||||
echo "Running database migrations..."
|
||||
docker-compose exec -T backend npx prisma migrate deploy
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
}
|
||||
|
||||
# Setup SSL certificates
|
||||
setup_ssl() {
|
||||
echo ""
|
||||
echo "Setting up SSL certificates..."
|
||||
|
||||
# Check if certificates exist
|
||||
if [ ! -f "$PROJECT_DIR/docker/nginx/ssl/cert.pem" ]; then
|
||||
echo "⚠️ SSL certificates not found at docker/nginx/ssl/"
|
||||
echo "Please place your certificates:"
|
||||
echo " - cert.pem (certificate)"
|
||||
echo " - key.pem (private key)"
|
||||
echo ""
|
||||
read -p "Continue without SSL? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Health check
|
||||
health_check() {
|
||||
echo ""
|
||||
echo "Running health checks..."
|
||||
|
||||
# Check if services are running
|
||||
if docker-compose ps | grep -q "Up"; then
|
||||
echo "✅ Services are running"
|
||||
else
|
||||
echo "❌ Some services failed to start"
|
||||
docker-compose ps
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test API endpoint
|
||||
if curl -sf http://localhost:3001/health > /dev/null; then
|
||||
echo "✅ Backend is responding"
|
||||
else
|
||||
echo "❌ Backend is not responding"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Health checks passed"
|
||||
}
|
||||
|
||||
# Main deployment flow
|
||||
main() {
|
||||
check_prerequisites
|
||||
load_env
|
||||
setup_ssl
|
||||
build_and_deploy
|
||||
health_check
|
||||
|
||||
echo ""
|
||||
echo "🎉 CoopCredits has been deployed!"
|
||||
echo ""
|
||||
echo "Access your application:"
|
||||
echo " - Website: https://coop.hobokenchicken.com"
|
||||
echo " - API: https://coop.hobokenchicken.com/api"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - View logs: docker-compose logs -f"
|
||||
echo " - Stop: docker-compose down"
|
||||
echo " - Restart: docker-compose restart"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# CoopCredits Health Check Script
|
||||
# Monitors all services in the infrastructure
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
PLEX_IP="172.20.1.220"
|
||||
OVERSEER_IP="172.20.1.225"
|
||||
TAUTULLI_IP="172.20.1.255"
|
||||
|
||||
PLEX_PORT="32400"
|
||||
OVERSEER_PORT="5055"
|
||||
TAUTULLI_PORT="8181"
|
||||
|
||||
# Track overall health
|
||||
HEALTHY=0
|
||||
UNHEALTHY=0
|
||||
|
||||
print_header() {
|
||||
echo ""
|
||||
echo "🏥 CoopCredits Health Check"
|
||||
echo "=========================="
|
||||
echo "$(date)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
check_service() {
|
||||
local name=$1
|
||||
local url=$2
|
||||
local expected_code=${3:-200}
|
||||
|
||||
echo -n "Checking $name... "
|
||||
|
||||
if curl -sf -o /dev/null -w "%{http_code}" "$url" 2>/dev/null | grep -q "$expected_code"; then
|
||||
echo -e "${GREEN}✅ OK${NC}"
|
||||
((HEALTHY++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ FAIL${NC}"
|
||||
((UNHEALTHY++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_docker_service() {
|
||||
local name=$1
|
||||
local container=$2
|
||||
|
||||
echo -n "Checking Docker container: $name... "
|
||||
|
||||
if docker ps --format "{{.Names}}" | grep -q "^${container}$"; then
|
||||
local status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null)
|
||||
if [ "$status" = "running" ]; then
|
||||
echo -e "${GREEN}✅ Running${NC}"
|
||||
((HEALTHY++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ Not running (status: $status)${NC}"
|
||||
((UNHEALTHY++))
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}❌ Not found${NC}"
|
||||
((UNHEALTHY++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_network_connectivity() {
|
||||
local name=$1
|
||||
local ip=$2
|
||||
local port=$3
|
||||
|
||||
echo -n "Network connectivity to $name ($ip:$port)... "
|
||||
|
||||
if timeout 5 bash -c "</dev/tcp/$ip/$port" 2>/dev/null; then
|
||||
echo -e "${GREEN}✅ Reachable${NC}"
|
||||
((HEALTHY++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ Unreachable${NC}"
|
||||
((UNHEALTHY++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_disk_space() {
|
||||
echo ""
|
||||
echo "📊 Disk Space"
|
||||
echo "-------------"
|
||||
|
||||
df -h / | awk 'NR==2 {printf "Root: %s used of %s (%s)\n", $3, $2, $5}'
|
||||
|
||||
local usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//')
|
||||
if [ "$usage" -gt 90 ]; then
|
||||
echo -e "${RED}⚠️ Critical disk usage!${NC}"
|
||||
((UNHEALTHY++))
|
||||
elif [ "$usage" -gt 80 ]; then
|
||||
echo -e "${YELLOW}⚠️ High disk usage${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✅ Disk usage OK${NC}"
|
||||
((HEALTHY++))
|
||||
fi
|
||||
}
|
||||
|
||||
check_memory() {
|
||||
echo ""
|
||||
echo "🧠 Memory Usage"
|
||||
echo "--------------"
|
||||
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
free -h | grep "Mem:" | awk '{printf "Used: %s / %s\n", $3, $2}'
|
||||
|
||||
local usage=$(free | grep "Mem:" | awk '{printf "%.0f", $3/$2 * 100}')
|
||||
if [ "$usage" -gt 90 ]; then
|
||||
echo -e "${RED}⚠️ Critical memory usage!${NC}"
|
||||
((UNHEALTHY++))
|
||||
elif [ "$usage" -gt 80 ]; then
|
||||
echo -e "${YELLOW}⚠️ High memory usage${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✅ Memory usage OK${NC}"
|
||||
((HEALTHY++))
|
||||
fi
|
||||
else
|
||||
echo "Memory check not available"
|
||||
fi
|
||||
}
|
||||
|
||||
check_ssl_certificate() {
|
||||
echo ""
|
||||
echo "🔒 SSL Certificate"
|
||||
echo "------------------"
|
||||
|
||||
local domain="coop.hobokenchicken.com"
|
||||
local expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -dates | grep notAfter | cut -d= -f2)
|
||||
|
||||
if [ -n "$expiry" ]; then
|
||||
local expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %H:%M:%S %Y %Z" "$expiry" +%s)
|
||||
local now_epoch=$(date +%s)
|
||||
local days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
|
||||
|
||||
echo "Certificate expires: $expiry ($days_left days)"
|
||||
|
||||
if [ "$days_left" -lt 7 ]; then
|
||||
echo -e "${RED}⚠️ Certificate expires soon!${NC}"
|
||||
((UNHEALTHY++))
|
||||
elif [ "$days_left" -lt 30 ]; then
|
||||
echo -e "${YELLOW}⚠️ Certificate expires in $days_left days${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✅ Certificate valid${NC}"
|
||||
((HEALTHY++))
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Could not check certificate${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
run_checks() {
|
||||
print_header
|
||||
|
||||
# Docker Services
|
||||
echo "🐳 Docker Services"
|
||||
echo "------------------"
|
||||
check_docker_service "Nginx" "coop-nginx"
|
||||
check_docker_service "Frontend" "coop-frontend"
|
||||
check_docker_service "Backend" "coop-backend"
|
||||
check_docker_service "PostgreSQL" "coop-postgres"
|
||||
check_docker_service "Redis" "coop-redis"
|
||||
|
||||
# Network Connectivity
|
||||
echo ""
|
||||
echo "🌐 Network Connectivity"
|
||||
echo "-----------------------"
|
||||
check_network_connectivity "Plex" "$PLEX_IP" "$PLEX_PORT"
|
||||
check_network_connectivity "Tautulli" "$TAUTULLI_IP" "$TAUTULLI_PORT"
|
||||
check_network_connectivity "Overseer" "$OVERSEER_IP" "$OVERSEER_PORT"
|
||||
|
||||
# External Services
|
||||
echo ""
|
||||
echo "🔗 External Services"
|
||||
echo "--------------------"
|
||||
check_service "CoopCredits Website" "https://coop.hobokenchicken.com/health"
|
||||
check_service "Backend API" "https://coop.hobokenchicken.com/api/health"
|
||||
|
||||
# System Resources
|
||||
check_disk_space
|
||||
check_memory
|
||||
|
||||
# SSL
|
||||
check_ssl_certificate
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "📋 Summary"
|
||||
echo "----------"
|
||||
echo -e "${GREEN}✅ $HEALTHY healthy${NC}"
|
||||
if [ $UNHEALTHY -gt 0 ]; then
|
||||
echo -e "${RED}❌ $UNHEALTHY unhealthy${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}All systems operational!${NC}"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Run in watch mode if requested
|
||||
if [ "$1" == "--watch" ]; then
|
||||
while true; do
|
||||
clear
|
||||
run_checks
|
||||
echo ""
|
||||
echo "Refreshing in 30 seconds... (Ctrl+C to stop)"
|
||||
sleep 30
|
||||
done
|
||||
else
|
||||
run_checks
|
||||
fi
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# CoopCredits Infrastructure Setup Script
|
||||
# Configures integration with existing services
|
||||
# Server Infrastructure: 172.20.1.0/24
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "🏗️ CoopCredits Infrastructure Setup"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ==========================================
|
||||
# Configuration
|
||||
# ==========================================
|
||||
PLEX_IP="172.20.1.220"
|
||||
OVERSEER_IP="172.20.1.225"
|
||||
TAUTULLI_IP="172.20.1.255"
|
||||
|
||||
PLEX_PORT="32400"
|
||||
OVERSEER_PORT="5055"
|
||||
TAUTULLI_PORT="8181"
|
||||
|
||||
echo "📋 Configuration:"
|
||||
echo " Plex: http://${PLEX_IP}:${PLEX_PORT}"
|
||||
echo " Overseer: http://${OVERSEER_IP}:${OVERSEER_PORT}"
|
||||
echo " Tautulli: http://${TAUTULLI_IP}:${TAUTULLI_PORT}"
|
||||
echo ""
|
||||
|
||||
# ==========================================
|
||||
# Test Connectivity
|
||||
# ==========================================
|
||||
test_connectivity() {
|
||||
echo "🔍 Testing connectivity to services..."
|
||||
|
||||
# Test Plex
|
||||
if curl -sf "http://${PLEX_IP}:${PLEX_PORT}/identity" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅${NC} Plex is reachable"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️${NC} Plex not responding (may need auth or be offline)"
|
||||
fi
|
||||
|
||||
# Test Tautulli
|
||||
if curl -sf "http://${TAUTULLI_IP}:${TAUTULLI_PORT}/api/v2?apikey=test&cmd=get_server_info" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅${NC} Tautulli is reachable"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️${NC} Tautulli not responding (check API key)"
|
||||
fi
|
||||
|
||||
# Test Overseer
|
||||
if curl -sf "http://${OVERSEER_IP}:${OVERSEER_PORT}/api/v1/status" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅${NC} Overseer is reachable"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️${NC} Overseer not responding (may need API key)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Generate Environment File
|
||||
# ==========================================
|
||||
generate_env() {
|
||||
echo "📝 Generating environment configuration..."
|
||||
|
||||
ENV_FILE="${PROJECT_DIR}/.env"
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo -e "${YELLOW}⚠️${NC} .env file already exists. Creating backup..."
|
||||
cp "$ENV_FILE" "${ENV_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
|
||||
# Generate secure keys
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
WEBHOOK_SECRET=$(openssl rand -hex 16)
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '=+/')
|
||||
REDIS_PASSWORD=$(openssl rand -base64 24 | tr -d '=+/')
|
||||
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# ==========================================
|
||||
# CoopCredits Environment Configuration
|
||||
# Generated: $(date)
|
||||
# Network: 172.20.1.0/24
|
||||
# ==========================================
|
||||
|
||||
# Database
|
||||
DATABASE_URL="postgresql://coop:${POSTGRES_PASSWORD}@postgres:5432/coop_credits?schema=public"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD}"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
API_URL=https://coop.hobokenchicken.com
|
||||
FRONTEND_URL=https://coop.hobokenchicken.com
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET="${JWT_SECRET}"
|
||||
|
||||
# ==========================================
|
||||
# Plex Server (172.20.1.220:32400)
|
||||
# ==========================================
|
||||
# Get from: https://plex.tv/claim
|
||||
PLEX_CLIENT_ID=""
|
||||
PLEX_CLIENT_SECRET=""
|
||||
PLEX_REDIRECT_URI="https://coop.hobokenchicken.com/auth/callback"
|
||||
|
||||
# ==========================================
|
||||
# Tautulli (172.20.1.255:8181)
|
||||
# ==========================================
|
||||
# Get from: Settings > Web Interface > API > API Key
|
||||
TAUTULLI_URL="http://${TAUTULLI_IP}:${TAUTULLI_PORT}"
|
||||
TAUTULLI_API_KEY=""
|
||||
TAUTULLI_WEBHOOK_SECRET="${WEBHOOK_SECRET}"
|
||||
|
||||
# ==========================================
|
||||
# Overseer (172.20.1.225:5055)
|
||||
# ==========================================
|
||||
# Get from: Settings > General > API Key
|
||||
OVERSEER_URL="http://${OVERSEER_IP}:${OVERSEER_PORT}"
|
||||
OVERSEER_API_KEY=""
|
||||
|
||||
# ==========================================
|
||||
# Solana (Devnet)
|
||||
# ==========================================
|
||||
# Run: npm run setup:solana to generate
|
||||
SOLANA_RPC_URL="https://api.devnet.solana.com"
|
||||
SOLANA_PROGRAM_ID="CoopCredits111111111111111111111111111111111"
|
||||
SOLANA_MINT_AUTHORITY_KEYPAIR=""
|
||||
SOLANA_TOKEN_DECIMALS=6
|
||||
|
||||
# ==========================================
|
||||
# Redis
|
||||
# ==========================================
|
||||
REDIS_URL="redis://:${REDIS_PASSWORD}@redis:6379"
|
||||
REDIS_PASSWORD="${REDIS_PASSWORD}"
|
||||
|
||||
# ==========================================
|
||||
# Encryption
|
||||
# ==========================================
|
||||
ENCRYPTION_KEY="${ENCRYPTION_KEY}"
|
||||
|
||||
# ==========================================
|
||||
# Frontend
|
||||
# ==========================================
|
||||
NEXT_PUBLIC_API_URL="https://coop.hobokenchicken.com"
|
||||
NEXT_PUBLIC_SOLANA_NETWORK="devnet"
|
||||
NEXT_PUBLIC_SOLANA_RPC_URL="https://api.devnet.solana.com"
|
||||
NEXT_PUBLIC_APP_NAME="CoopCredits"
|
||||
NEXT_PUBLIC_APP_URL="https://coop.hobokenchicken.com"
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✅${NC} Environment file created: ${ENV_FILE}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️${NC} IMPORTANT: Edit .env and add your API keys:"
|
||||
echo " 1. PLEX_CLIENT_ID and PLEX_CLIENT_SECRET from plex.tv"
|
||||
echo " 2. TAUTULLI_API_KEY from Tautulli settings"
|
||||
echo " 3. OVERSEER_API_KEY from Overseer settings"
|
||||
echo " 4. SOLANA_MINT_AUTHORITY_KEYPAIR from 'npm run setup:solana'"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Configure Tautulli Webhook
|
||||
# ==========================================
|
||||
configure_tautulli() {
|
||||
echo "📺 Tautulli Webhook Configuration"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
echo "To complete Tautulli integration:"
|
||||
echo ""
|
||||
echo "1. Open Tautulli: http://${TAUTULLI_IP}:${TAUTULLI_PORT}"
|
||||
echo "2. Go to: Settings > Notification Agents"
|
||||
echo "3. Click 'Add a new notification agent'"
|
||||
echo "4. Select 'Webhook'"
|
||||
echo ""
|
||||
echo "Configure the webhook:"
|
||||
echo " Webhook URL: https://coop.hobokenchicken.com/webhooks/tautulli"
|
||||
echo " Webhook Method: POST"
|
||||
echo " Content Type: application/json"
|
||||
echo ""
|
||||
echo "JSON Payload (copy this exactly):"
|
||||
cat << 'JSONEOF'
|
||||
{
|
||||
"action": "watched",
|
||||
"user_id": "{user_id}",
|
||||
"username": "{username}",
|
||||
"rating_key": "{rating_key}",
|
||||
"session_key": "{session_key}",
|
||||
"media_type": "{media_type}",
|
||||
"title": "{title}",
|
||||
"grandparent_title": "{grandparent_title}",
|
||||
"started": "{started}",
|
||||
"stopped": "{stopped}",
|
||||
"percent_complete": "{percent_complete}",
|
||||
"is_new": "{is_new}"
|
||||
}
|
||||
JSONEOF
|
||||
echo ""
|
||||
echo "Triggers: Enable 'Watched'"
|
||||
echo "Conditions: None (or customize as needed)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Configure Overseer Webhook
|
||||
# ==========================================
|
||||
configure_overseer() {
|
||||
echo "🎬 Overseer Webhook Configuration"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
echo "To complete Overseer integration:"
|
||||
echo ""
|
||||
echo "1. Open Overseer: http://${OVERSEER_IP}:${OVERSEER_PORT}"
|
||||
echo "2. Go to: Settings > Notifications"
|
||||
echo "3. Click 'Webhook'"
|
||||
echo ""
|
||||
echo "Configure the webhook:"
|
||||
echo " Webhook URL: https://coop.hobokenchicken.com/webhooks/overseer"
|
||||
echo " Authorization Header: Bearer <your-webhook-secret-from-env>"
|
||||
echo ""
|
||||
echo "JSON Payload:"
|
||||
cat << 'JSONEOF'
|
||||
{
|
||||
"request_id": "{{request.id}}",
|
||||
"status": "{{request.status}}",
|
||||
"media_type": "{{media.media_type}}",
|
||||
"title": "{{media.title}}"
|
||||
}
|
||||
JSONEOF
|
||||
echo ""
|
||||
echo "Events: Enable 'Request Approved' and 'Request Declined'"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Configure Plex OAuth
|
||||
# ==========================================
|
||||
configure_plex() {
|
||||
echo "🎭 Plex OAuth Configuration"
|
||||
echo "==========================="
|
||||
echo ""
|
||||
echo "To enable Plex authentication:"
|
||||
echo ""
|
||||
echo "1. Go to: https://plex.tv/claim"
|
||||
echo " Or: https://www.plex.tv/link/"
|
||||
echo ""
|
||||
echo "2. For OAuth app registration, you may need to contact Plex"
|
||||
echo " or use a third-party OAuth provider that supports Plex"
|
||||
echo ""
|
||||
echo "3. Alternative: Use custom authentication with Plex credentials"
|
||||
echo " stored securely and linked to wallets"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Firewall Rules
|
||||
# ==========================================
|
||||
configure_firewall() {
|
||||
echo "🔥 Firewall Configuration"
|
||||
echo "========================="
|
||||
echo ""
|
||||
echo "Recommended firewall rules for CoopCredits server:"
|
||||
echo ""
|
||||
echo "# Allow web traffic"
|
||||
echo "sudo ufw allow 80/tcp"
|
||||
echo "sudo ufw allow 443/tcp"
|
||||
echo ""
|
||||
echo "# Allow internal network access to services"
|
||||
echo "sudo ufw allow from 172.20.1.0/24 to any port 3001"
|
||||
echo ""
|
||||
echo "# Allow PostgreSQL only from localhost"
|
||||
echo "sudo ufw allow from 127.0.0.1 to any port 5432"
|
||||
echo ""
|
||||
echo "# Allow Redis only from localhost"
|
||||
echo "sudo ufw allow from 127.0.0.1 to any port 6379"
|
||||
echo ""
|
||||
echo "Current UFW status:"
|
||||
sudo ufw status verbose 2>/dev/null || echo "UFW not installed or not active"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# SSL Certificate Setup
|
||||
# ==========================================
|
||||
setup_ssl() {
|
||||
echo "🔒 SSL Certificate Setup"
|
||||
echo "======================="
|
||||
echo ""
|
||||
|
||||
SSL_DIR="${PROJECT_DIR}/docker/nginx/ssl"
|
||||
mkdir -p "$SSL_DIR"
|
||||
|
||||
if [ -f "${SSL_DIR}/cert.pem" ] && [ -f "${SSL_DIR}/key.pem" ]; then
|
||||
echo -e "${GREEN}✅${NC} SSL certificates already exist"
|
||||
else
|
||||
echo "SSL certificates not found at ${SSL_DIR}/"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo ""
|
||||
echo "1. Let's Encrypt (recommended for production):"
|
||||
echo " docker-compose -f docker-compose.prod.yml run --rm certbot certonly"
|
||||
echo " --webroot -w /var/www/certbot"
|
||||
echo " -d coop.hobokenchicken.com"
|
||||
echo " --agree-tos --no-eff-email"
|
||||
echo ""
|
||||
echo "2. Self-signed (for testing only):"
|
||||
echo " openssl req -x509 -nodes -days 365 -newkey rsa:2048 \\"
|
||||
echo " -keyout ${SSL_DIR}/key.pem \\"
|
||||
echo " -out ${SSL_DIR}/cert.pem \\"
|
||||
echo " -subj '/CN=coop.hobokenchicken.com'"
|
||||
echo ""
|
||||
echo "3. Place existing certificates:"
|
||||
echo " cp your-cert.pem ${SSL_DIR}/cert.pem"
|
||||
echo " cp your-key.pem ${SSL_DIR}/key.pem"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Main
|
||||
# ==========================================
|
||||
main() {
|
||||
test_connectivity
|
||||
generate_env
|
||||
setup_ssl
|
||||
configure_tautulli
|
||||
configure_overseer
|
||||
configure_plex
|
||||
configure_firewall
|
||||
|
||||
echo ""
|
||||
echo "🎉 Setup complete!"
|
||||
echo "=================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit .env file with your API keys"
|
||||
echo " 2. Run: npm run setup:solana"
|
||||
echo " 3. Setup SSL certificates"
|
||||
echo " 4. Run: npm run deploy"
|
||||
echo ""
|
||||
echo "After deployment, configure webhooks in Tautulli and Overseer"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Solana Setup Script for CoopCredits
|
||||
# This script sets up the Solana environment for the CoopCredits token
|
||||
|
||||
set -e
|
||||
|
||||
echo "🪙 CoopCredits Solana Setup"
|
||||
echo "==========================="
|
||||
echo ""
|
||||
|
||||
# Check for Solana CLI
|
||||
if ! command -v solana &> /dev/null; then
|
||||
echo "Installing Solana CLI..."
|
||||
sh -c "$(curl -sSfL https://release.solana.com/v1.17.0/install)"
|
||||
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
|
||||
fi
|
||||
|
||||
echo "✅ Solana CLI version: $(solana --version)"
|
||||
|
||||
# Set network to devnet
|
||||
echo ""
|
||||
echo "Setting network to devnet..."
|
||||
solana config set --url devnet
|
||||
|
||||
# Create or load keypair
|
||||
KEYPAIR_PATH="$HOME/.config/solana/coop-credits-authority.json"
|
||||
|
||||
if [ -f "$KEYPAIR_PATH" ]; then
|
||||
echo "✅ Using existing keypair: $KEYPAIR_PATH"
|
||||
else
|
||||
echo "Creating new keypair..."
|
||||
solana-keygen new --outfile "$KEYPAIR_PATH" --no-bip39-passphrase
|
||||
echo "✅ Keypair created: $KEYPAIR_PATH"
|
||||
fi
|
||||
|
||||
# Get public key
|
||||
AUTHORITY_PUBKEY=$(solana-keygen pubkey "$KEYPAIR_PATH")
|
||||
echo ""
|
||||
echo "Authority Public Key: $AUTHORITY_PUBKEY"
|
||||
|
||||
# Request airdrop
|
||||
echo ""
|
||||
echo "Requesting airdrop..."
|
||||
solana airdrop 2 "$AUTHORITY_PUBKEY" || echo "⚠️ Airdrop failed (may have rate limit)"
|
||||
|
||||
# Check balance
|
||||
BALANCE=$(solana balance "$AUTHORITY_PUBKEY")
|
||||
echo "Authority Balance: $BALANCE SOL"
|
||||
|
||||
# Export private key for backend
|
||||
echo ""
|
||||
echo "Exporting private key for backend..."
|
||||
PRIVATE_KEY=$(cat "$KEYPAIR_PATH" | jq -r '.[]' | xxd -p -c 999999 | head -c 128)
|
||||
BASE58_KEY=$(solana-keygen recover --force --outfile /dev/stdout "$KEYPAIR_PATH" 2>/dev/null | head -1)
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "IMPORTANT: Add these to your backend .env file:"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "SOLANA_MINT_AUTHORITY_KEYPAIR=$BASE58_KEY"
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Build Anchor program
|
||||
echo "Building Anchor program..."
|
||||
cd ../anchor-program
|
||||
|
||||
if command -v anchor &> /dev/null; then
|
||||
anchor build
|
||||
echo "✅ Program built successfully"
|
||||
|
||||
echo ""
|
||||
echo "To deploy the program, run:"
|
||||
echo " cd anchor-program"
|
||||
echo " anchor deploy"
|
||||
else
|
||||
echo "⚠️ Anchor not installed. Install with:"
|
||||
echo " npm install -g @coral-xyz/anchor-cli"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Setup complete!"
|
||||
@@ -0,0 +1,220 @@
|
||||
version: '3.8'
|
||||
|
||||
# ==========================================
|
||||
# CoopCredits Production Docker Compose
|
||||
# Server Infrastructure: 172.20.1.0/24
|
||||
# ==========================================
|
||||
|
||||
services:
|
||||
# ==========================================
|
||||
# PostgreSQL Database
|
||||
# ==========================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: coop-postgres
|
||||
environment:
|
||||
POSTGRES_USER: coop
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-coop_secure_password_change_me}
|
||||
POSTGRES_DB: coop_credits
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # Only accessible locally
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U coop -d coop_credits"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- coop-internal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ==========================================
|
||||
# Redis Cache
|
||||
# ==========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: coop-redis
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-redis_secure_password}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # Only accessible locally
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- coop-internal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ==========================================
|
||||
# Backend API Server
|
||||
# ==========================================
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: coop-backend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://coop:${POSTGRES_PASSWORD:-coop_secure_password}@postgres:5432/coop_credits?schema=public
|
||||
- REDIS_URL=redis://:${REDIS_PASSWORD:-redis_secure_password}@redis:6379
|
||||
- PORT=3001
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- API_URL=https://coop.hobokenchicken.com
|
||||
- FRONTEND_URL=https://coop.hobokenchicken.com
|
||||
# Plex
|
||||
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID}
|
||||
- PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET}
|
||||
- PLEX_REDIRECT_URI=${PLEX_REDIRECT_URI:-https://coop.hobokenchicken.com/auth/callback}
|
||||
# Tautulli (172.20.1.255:8181)
|
||||
- TAUTULLI_URL=http://172.20.1.255:8181
|
||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||
- TAUTULLI_WEBHOOK_SECRET=${TAUTULLI_WEBHOOK_SECRET}
|
||||
# Overseer (172.20.1.225:5055)
|
||||
- OVERSEER_URL=http://172.20.1.225:5055
|
||||
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
||||
# Solana
|
||||
- SOLANA_RPC_URL=${SOLANA_RPC_URL:-https://api.devnet.solana.com}
|
||||
- SOLANA_PROGRAM_ID=${SOLANA_PROGRAM_ID}
|
||||
- SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR}
|
||||
- SOLANA_TOKEN_DECIMALS=6
|
||||
# Security
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- TRUST_PROXY=true
|
||||
ports:
|
||||
- "127.0.0.1:3001:3001" # Only accessible via Nginx
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- coop-internal
|
||||
# Allow access to local network for Tautulli/Overseer/Plex
|
||||
- coop-external
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ==========================================
|
||||
# Frontend Next.js App
|
||||
# ==========================================
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=https://coop.hobokenchicken.com
|
||||
- NEXT_PUBLIC_SOLANA_NETWORK=devnet
|
||||
- NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
container_name: coop-frontend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=https://coop.hobokenchicken.com
|
||||
- NEXT_PUBLIC_SOLANA_NETWORK=devnet
|
||||
- NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Only accessible via Nginx
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- coop-internal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
# ==========================================
|
||||
# Nginx Reverse Proxy
|
||||
# ==========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: coop-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ./docker/nginx/logs:/var/log/nginx
|
||||
# Let's Encrypt certificates (if using certbot)
|
||||
- ./docker/nginx/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./docker/nginx/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- coop-internal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
# ==========================================
|
||||
# Certbot (for Let's Encrypt SSL)
|
||||
# ==========================================
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: coop-certbot
|
||||
volumes:
|
||||
- ./docker/nginx/letsencrypt:/etc/letsencrypt
|
||||
- ./docker/nginx/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
networks:
|
||||
- coop-internal
|
||||
restart: unless-stopped
|
||||
|
||||
# ==========================================
|
||||
# Volumes
|
||||
# ==========================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
# ==========================================
|
||||
# Networks
|
||||
# ==========================================
|
||||
networks:
|
||||
# Internal network for container communication
|
||||
coop-internal:
|
||||
driver: bridge
|
||||
internal: false
|
||||
|
||||
# External network for accessing local services (172.20.1.0/24)
|
||||
coop-external:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.2.0/24
|
||||
gateway: 172.20.2.1
|
||||
@@ -0,0 +1,109 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: coop-postgres
|
||||
environment:
|
||||
POSTGRES_USER: coop
|
||||
POSTGRES_PASSWORD: coop_password
|
||||
POSTGRES_DB: coop_credits
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U coop -d coop_credits"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- coop-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: coop-redis
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- coop-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: coop-backend
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://coop:coop_password@postgres:5432/coop_credits?schema=public
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- PORT=3001
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- SOLANA_RPC_URL=${SOLANA_RPC_URL:-https://api.devnet.solana.com}
|
||||
- SOLANA_PROGRAM_ID=${SOLANA_PROGRAM_ID}
|
||||
- SOLANA_MINT_AUTHORITY_KEYPAIR=${SOLANA_MINT_AUTHORITY_KEYPAIR}
|
||||
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID}
|
||||
- PLEX_CLIENT_SECRET=${PLEX_CLIENT_SECRET}
|
||||
- TAUTULLI_URL=${TAUTULLI_URL}
|
||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||
- OVERSEER_URL=${OVERSEER_URL}
|
||||
- OVERSEER_API_KEY=${OVERSEER_API_KEY}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- coop-network
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: coop-frontend
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
- NEXT_PUBLIC_SOLANA_NETWORK=devnet
|
||||
- NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- coop-network
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: coop-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- coop-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
coop-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,119 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=100r/s;
|
||||
|
||||
# Upstream servers
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server backend:3001;
|
||||
}
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name coop.hobokenchicken.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name coop.hobokenchicken.com;
|
||||
|
||||
# SSL certificates (replace with your actual certificates)
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# SSL settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://backend/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Webhooks (higher rate limit)
|
||||
location /webhooks/ {
|
||||
limit_req zone=webhooks burst=50 nodelay;
|
||||
proxy_pass http://backend/webhooks/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# ==========================================
|
||||
# CoopCredits Nginx Configuration
|
||||
# Production - coop.hobokenchicken.com
|
||||
# ==========================================
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=100r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_conn_zone $binary_remote_addr zone=addr:10m;
|
||||
|
||||
# Upstream servers
|
||||
upstream frontend {
|
||||
server frontend:3000 max_fails=3 fail_timeout=30s;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server backend:3001 max_fails=3 fail_timeout=30s;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# HTTP Server - Redirect to HTTPS
|
||||
# ==========================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name coop.hobokenchicken.com;
|
||||
|
||||
# Let's Encrypt challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# HTTPS Server - Main Application
|
||||
# ==========================================
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name coop.hobokenchicken.com;
|
||||
|
||||
# SSL Certificates
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# Alternative: Let's Encrypt certificates
|
||||
# ssl_certificate /etc/letsencrypt/live/coop.hobokenchicken.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/coop.hobokenchicken.com/privkey.pem;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.devnet.solana.com wss:;" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# ==========================================
|
||||
# Frontend - Next.js App
|
||||
# ==========================================
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# API Endpoints
|
||||
# ==========================================
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
limit_conn addr 10;
|
||||
|
||||
proxy_pass http://backend/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Webhooks (higher rate limit)
|
||||
# ==========================================
|
||||
location /webhooks/ {
|
||||
limit_req zone=webhooks burst=50 nodelay;
|
||||
|
||||
proxy_pass http://backend/webhooks/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# WebSocket Support (Socket.io)
|
||||
# ==========================================
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Static Assets (cache optimization)
|
||||
# ==========================================
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://frontend;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Health Check Endpoint
|
||||
# ==========================================
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Error Pages
|
||||
# ==========================================
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# CoopCredits Infrastructure Documentation
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ External Access │
|
||||
│ coop.hobokenchicken.com │
|
||||
└─────────────────────────────────┬───────────────────────────────────────┘
|
||||
│ HTTPS (443)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ CoopCredits Server │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Nginx │ │ Frontend │ │ Backend │ │ PostgreSQL │ │
|
||||
│ │ (80/443) │──│ (Next.js) │──│ (Express) │──│ (5432) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────┬───────┘ └──────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │ ┌──────────────┐ │
|
||||
│ │ Certbot │ │ Redis │◀────────┘ │ Anchor CLI │ │
|
||||
│ │ (SSL) │ │ (6379) │ │ (Optional) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└──────────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
│ Local Network (172.20.1.0/24)
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Plex │ │ Tautulli │ │ Overseer │
|
||||
│172.20.1.220 │ │172.20.1.255 │ │172.20.1.225 │
|
||||
│ :32400 │ │ :8181 │ │ :5055 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Service Details
|
||||
|
||||
### Plex Server (172.20.1.220:32400)
|
||||
- **Purpose**: Content streaming and user authentication
|
||||
- **Integration**: OAuth authentication for website login
|
||||
- **Access**: HTTP on local network, may have remote access enabled
|
||||
|
||||
### Tautulli (172.20.1.255:8181)
|
||||
- **Purpose**: Plex analytics and watch event tracking
|
||||
- **Integration**: Webhook notifications to CoopCredits backend
|
||||
- **Access**: HTTP on local network
|
||||
- **API Key**: Required for backend queries
|
||||
|
||||
### Overseer (172.20.1.225:5055)
|
||||
- **Purpose**: Content request management
|
||||
- **Integration**: API for requesting content, webhooks for status updates
|
||||
- **Access**: HTTP on local network
|
||||
- **API Key**: Required for backend integration
|
||||
|
||||
### CoopCredits Server
|
||||
- **Public Access**: coop.hobokenchicken.com (HTTPS)
|
||||
- **Internal Services**: Only accessible via Nginx reverse proxy
|
||||
- **Database**: PostgreSQL on localhost only
|
||||
- **Cache**: Redis on localhost only
|
||||
|
||||
## Communication Flow
|
||||
|
||||
### 1. User Authentication
|
||||
```
|
||||
User → Nginx → Frontend → Backend → Plex OAuth (172.20.1.220:32400)
|
||||
↓
|
||||
User authenticated, JWT issued
|
||||
```
|
||||
|
||||
### 2. Watch Event Processing
|
||||
```
|
||||
Plex → Tautulli → Webhook → Nginx → Backend → Solana Devnet
|
||||
↓
|
||||
Database updated
|
||||
↓
|
||||
WebSocket → User notified
|
||||
```
|
||||
|
||||
### 3. Content Request
|
||||
```
|
||||
User → Nginx → Frontend → Backend → Overseer API (172.20.1.225:5055)
|
||||
↓
|
||||
Request created, $COOP reserved
|
||||
↓
|
||||
Webhook on approval → Burn $COOP
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Network Security
|
||||
1. **Local Network**: All services communicate over HTTP (trusted network)
|
||||
2. **External Access**: Only Nginx exposed (ports 80/443)
|
||||
3. **Internal Services**: Not accessible from external network
|
||||
|
||||
### API Security
|
||||
1. **Tautulli Webhook**: Secret verification recommended
|
||||
2. **Overseer API**: API key authentication
|
||||
3. **Plex OAuth**: Standard OAuth 2.0 flow
|
||||
4. **JWT**: Secure tokens for session management
|
||||
|
||||
### Data Security
|
||||
1. **Wallet Keys**: Encrypted with AES-256-GCM in database
|
||||
2. **Database**: Not exposed externally
|
||||
3. **Redis**: Password protected, localhost only
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
```bash
|
||||
# Web traffic
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Internal service access (from Docker containers)
|
||||
sudo ufw allow from 172.20.0.0/16 to 172.20.1.0/24
|
||||
|
||||
# Block external access to internal services
|
||||
sudo ufw deny 3000/tcp # Frontend
|
||||
sudo ufw deny 3001/tcp # Backend
|
||||
sudo ufw deny 5432/tcp # PostgreSQL
|
||||
sudo ufw deny 6379/tcp # Redis
|
||||
```
|
||||
|
||||
## Docker Network Configuration
|
||||
|
||||
### Internal Network (coop-internal)
|
||||
- Containers can communicate with each other
|
||||
- Isolated from external network
|
||||
- Used for: frontend ↔ backend ↔ database
|
||||
|
||||
### External Network (coop-external)
|
||||
- Allows containers to reach local services
|
||||
- Subnet: 172.20.2.0/24
|
||||
- Used for: backend → Tautulli/Overseer/Plex
|
||||
|
||||
## SSL/TLS Setup
|
||||
|
||||
### Let's Encrypt (Recommended)
|
||||
```bash
|
||||
# Initial certificate
|
||||
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d coop.hobokenchicken.com \
|
||||
--agree-tos --no-eff-email
|
||||
|
||||
# Auto-renewal (configured in docker-compose)
|
||||
```
|
||||
|
||||
### Self-Signed (Testing only)
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/key.pem \
|
||||
-out docker/nginx/ssl/cert.pem \
|
||||
-subj '/CN=coop.hobokenchicken.com'
|
||||
```
|
||||
|
||||
## Monitoring and Logging
|
||||
|
||||
### Log Locations
|
||||
- Nginx: `docker/nginx/logs/`
|
||||
- Backend: Docker logs (`docker-compose logs backend`)
|
||||
- Frontend: Docker logs (`docker-compose logs frontend`)
|
||||
- Database: Inside container (`/var/log/postgresql/`)
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Backend health
|
||||
curl https://coop.hobokenchicken.com/health
|
||||
|
||||
# Database connection
|
||||
docker-compose exec postgres pg_isready -U coop
|
||||
|
||||
# Service status
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot reach local services
|
||||
1. Check Docker network: `docker network inspect coop-credits_coop-external`
|
||||
2. Verify IP connectivity: `docker exec coop-backend ping 172.20.1.255`
|
||||
3. Check firewall rules: `sudo ufw status`
|
||||
|
||||
### Webhook not received
|
||||
1. Verify Tautulli can reach CoopCredits:
|
||||
```bash
|
||||
curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"test": true}'
|
||||
```
|
||||
2. Check Nginx logs: `tail -f docker/nginx/logs/access.log`
|
||||
3. Check backend logs: `docker-compose logs -f backend`
|
||||
|
||||
### CORS errors
|
||||
1. Verify CORS_ORIGINS in .env includes your domain
|
||||
2. Check backend is sending correct headers
|
||||
3. Nginx should pass through CORS headers
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Nginx Tuning
|
||||
- `worker_processes auto` - Use all CPU cores
|
||||
- `worker_connections 1024` - High connection limit
|
||||
- `gzip on` - Compress responses
|
||||
- `proxy_cache` - Cache static assets
|
||||
|
||||
### Database Tuning
|
||||
- Connection pooling via Prisma
|
||||
- Redis for session caching
|
||||
- Indexed queries on user_id, created_at
|
||||
|
||||
### Frontend Optimization
|
||||
- Next.js static generation where possible
|
||||
- Image optimization
|
||||
- Code splitting
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
```bash
|
||||
# Automated backup script
|
||||
docker-compose exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Wallet Recovery
|
||||
- Private keys are encrypted in database
|
||||
- Backup keys stored securely (encrypted)
|
||||
- Recovery requires encryption key from .env
|
||||
|
||||
### Configuration Backup
|
||||
- `.env` file (contains all secrets)
|
||||
- `docker/nginx/ssl/` certificates
|
||||
- `docker-compose.prod.yml` service config
|
||||
@@ -0,0 +1,372 @@
|
||||
# CoopCredits Setup for 172.20.1.0/24 Infrastructure
|
||||
|
||||
This guide covers setting up CoopCredits with your existing Plex/Tautulli/Overseer infrastructure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Server running Docker and Docker Compose
|
||||
- Access to 172.20.1.0/24 network
|
||||
- API keys from Tautulli and Overseer
|
||||
- Domain name (coop.hobokenchicken.com) pointing to your server
|
||||
|
||||
## Network Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CoopCredits Server │
|
||||
│ (Your Server IP) │
|
||||
│ :443 │
|
||||
└─────────────────────────────────┬───────────────────────────────┘
|
||||
│ HTTPS
|
||||
┌─────────────────────────┼──────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Plex │ │ Tautulli │ │ Overseer │
|
||||
│172.20.1.220 │ │172.20.1.255 │ │172.20.1.225 │
|
||||
│ :32400 │ │ :8181 │ │ :5055 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone <repository> coop-credits
|
||||
cd coop-credits
|
||||
```
|
||||
|
||||
### 2. Run Infrastructure Setup
|
||||
|
||||
```bash
|
||||
./deployment/setup-infrastructure.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Test connectivity to your services (Plex, Tautulli, Overseer)
|
||||
- Generate a secure `.env` file
|
||||
- Check SSL certificate status
|
||||
- Output configuration instructions
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Edit the generated `.env` file:
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
Add your API keys:
|
||||
|
||||
```env
|
||||
# Get from Tautulli: Settings > Web Interface > API
|
||||
TAUTULLI_API_KEY=your-tautulli-api-key
|
||||
|
||||
# Get from Overseer: Settings > General > API Key
|
||||
OVERSEER_API_KEY=your-overseer-api-key
|
||||
|
||||
# Get from https://plex.tv/claim or Plex settings
|
||||
PLEX_CLIENT_ID=your-plex-client-id
|
||||
PLEX_CLIENT_SECRET=your-plex-client-secret
|
||||
```
|
||||
|
||||
### 4. Setup Solana
|
||||
|
||||
```bash
|
||||
npm run setup:solana
|
||||
```
|
||||
|
||||
This will:
|
||||
- Install Solana CLI
|
||||
- Create a devnet wallet
|
||||
- Request airdrop
|
||||
- Output the private key for your `.env` file
|
||||
|
||||
Copy the `SOLANA_MINT_AUTHORITY_KEYPAIR` into your `.env` file.
|
||||
|
||||
### 5. Deploy Solana Program
|
||||
|
||||
```bash
|
||||
cd anchor-program
|
||||
anchor build
|
||||
anchor deploy
|
||||
```
|
||||
|
||||
Update `SOLANA_PROGRAM_ID` in `.env` with the deployed program ID.
|
||||
|
||||
### 6. Setup SSL Certificates
|
||||
|
||||
#### Option A: Let's Encrypt (Recommended)
|
||||
|
||||
```bash
|
||||
# Obtain certificate
|
||||
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d coop.hobokenchicken.com \
|
||||
--agree-tos --no-eff-email
|
||||
|
||||
# Update nginx config to use Let's Encrypt paths
|
||||
# Edit docker/nginx/nginx.prod.conf:
|
||||
# ssl_certificate /etc/letsencrypt/live/coop.hobokenchicken.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/coop.hobokenchicken.com/privkey.pem;
|
||||
```
|
||||
|
||||
#### Option B: Existing Certificates
|
||||
|
||||
```bash
|
||||
cp /path/to/your/cert.pem docker/nginx/ssl/cert.pem
|
||||
cp /path/to/your/key.pem docker/nginx/ssl/key.pem
|
||||
```
|
||||
|
||||
#### Option C: Self-Signed (Testing only)
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/key.pem \
|
||||
-out docker/nginx/ssl/cert.pem \
|
||||
-subj '/CN=coop.hobokenchicken.com'
|
||||
```
|
||||
|
||||
### 7. Deploy
|
||||
|
||||
```bash
|
||||
./deployment/deploy-production.sh
|
||||
```
|
||||
|
||||
### 8. Configure Tautulli Webhook
|
||||
|
||||
1. Open Tautulli: http://172.20.1.255:8181
|
||||
2. Go to **Settings > Notification Agents**
|
||||
3. Click **Add a new notification agent > Webhook**
|
||||
|
||||
**Configuration:**
|
||||
- Webhook URL: `https://coop.hobokenchicken.com/webhooks/tautulli`
|
||||
- Webhook Method: `POST`
|
||||
- Content Type: `application/json`
|
||||
|
||||
**JSON Payload:**
|
||||
```json
|
||||
{
|
||||
"action": "watched",
|
||||
"user_id": "{user_id}",
|
||||
"username": "{username}",
|
||||
"rating_key": "{rating_key}",
|
||||
"session_key": "{session_key}",
|
||||
"media_type": "{media_type}",
|
||||
"title": "{title}",
|
||||
"grandparent_title": "{grandparent_title}",
|
||||
"started": "{started}",
|
||||
"stopped": "{stopped}",
|
||||
"percent_complete": "{percent_complete}",
|
||||
"is_new": "{is_new}"
|
||||
}
|
||||
```
|
||||
|
||||
**Triggers:** Enable **Watched**
|
||||
|
||||
### 9. Configure Overseer Webhook
|
||||
|
||||
1. Open Overseer: http://172.20.1.225:5055
|
||||
2. Go to **Settings > Notifications**
|
||||
3. Enable **Webhook**
|
||||
|
||||
**Configuration:**
|
||||
- Webhook URL: `https://coop.hobokenchicken.com/webhooks/overseer`
|
||||
- Authorization Header: `Bearer your-webhook-secret-from-env`
|
||||
|
||||
**JSON Payload:**
|
||||
```json
|
||||
{
|
||||
"request_id": "{{request.id}}",
|
||||
"status": "{{request.status}}",
|
||||
"media_type": "{{media.media_type}}",
|
||||
"title": "{{media.title}}"
|
||||
}
|
||||
```
|
||||
|
||||
**Events:** Enable **Request Approved** and **Request Declined**
|
||||
|
||||
## Verification
|
||||
|
||||
### Test Connectivity
|
||||
|
||||
```bash
|
||||
./deployment/health-check.sh
|
||||
```
|
||||
|
||||
### Watch Mode
|
||||
|
||||
```bash
|
||||
./deployment/health-check.sh --watch
|
||||
```
|
||||
|
||||
### Manual Tests
|
||||
|
||||
**Test Tautulli webhook:**
|
||||
```bash
|
||||
curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"action": "watched",
|
||||
"user_id": "12345",
|
||||
"username": "testuser",
|
||||
"rating_key": "1234",
|
||||
"session_key": "abc123",
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"started": "'$(date +%s)'",
|
||||
"stopped": "'$(($(date +%s) + 3600))'",
|
||||
"percent_complete": "90"
|
||||
}'
|
||||
```
|
||||
|
||||
**Test API:**
|
||||
```bash
|
||||
curl https://coop.hobokenchicken.com/api/health
|
||||
```
|
||||
|
||||
## Firewall Configuration
|
||||
|
||||
If using UFW:
|
||||
|
||||
```bash
|
||||
# Allow web traffic
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
|
||||
# Allow backend to reach local network
|
||||
sudo ufw allow from 172.20.2.0/16 to 172.20.1.0/24
|
||||
|
||||
# Deny direct access to internal services
|
||||
sudo ufw deny 3000/tcp
|
||||
sudo ufw deny 3001/tcp
|
||||
sudo ufw deny 5432/tcp
|
||||
sudo ufw deny 6379/tcp
|
||||
|
||||
# Enable firewall
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot reach local services
|
||||
|
||||
1. Check Docker network:
|
||||
```bash
|
||||
docker network inspect coop-credits_coop-external
|
||||
```
|
||||
|
||||
2. Test connectivity from container:
|
||||
```bash
|
||||
docker exec coop-backend ping 172.20.1.255
|
||||
```
|
||||
|
||||
3. Verify firewall rules:
|
||||
```bash
|
||||
sudo ufw status verbose
|
||||
```
|
||||
|
||||
### Webhooks not working
|
||||
|
||||
1. Check Nginx logs:
|
||||
```bash
|
||||
tail -f docker/nginx/logs/access.log
|
||||
```
|
||||
|
||||
2. Check backend logs:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
3. Test webhook manually:
|
||||
```bash
|
||||
curl -X POST https://coop.hobokenchicken.com/webhooks/tautulli \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"test": true}'
|
||||
```
|
||||
|
||||
### Database connection issues
|
||||
|
||||
1. Check database status:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml ps postgres
|
||||
```
|
||||
|
||||
2. View database logs:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs postgres
|
||||
```
|
||||
|
||||
3. Test connection:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec postgres pg_isready -U coop
|
||||
```
|
||||
|
||||
### SSL certificate issues
|
||||
|
||||
1. Check certificate:
|
||||
```bash
|
||||
openssl s_client -connect coop.hobokenchicken.com:443 -servername coop.hobokenchicken.com
|
||||
```
|
||||
|
||||
2. Verify certificate paths in nginx config
|
||||
|
||||
3. Check certificate expiry:
|
||||
```bash
|
||||
openssl x509 -in docker/nginx/ssl/cert.pem -noout -dates
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
cd /opt/coop-credits
|
||||
git pull
|
||||
./deployment/deploy-production.sh
|
||||
```
|
||||
|
||||
### Backup Database
|
||||
|
||||
```bash
|
||||
# Automated backup
|
||||
docker-compose -f docker-compose.prod.yml exec -T postgres pg_dump -U coop coop_credits > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml restart backend
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Changed all default passwords in `.env`
|
||||
- [ ] SSL certificates installed and valid
|
||||
- [ ] Firewall rules configured
|
||||
- [ ] Tautulli webhook secret set
|
||||
- [ ] Overseer webhook secret set
|
||||
- [ ] JWT secret is random and secure
|
||||
- [ ] Database not exposed externally
|
||||
- [ ] Redis password set
|
||||
- [ ] Encryption key is random and backed up
|
||||
- [ ] Solana mint authority key backed up securely
|
||||
|
||||
## Support
|
||||
|
||||
For issues:
|
||||
1. Check health: `./deployment/health-check.sh`
|
||||
2. Review logs: `docker-compose -f docker-compose.prod.yml logs`
|
||||
3. Check documentation in `docs/INFRASTRUCTURE.md`
|
||||
@@ -0,0 +1,10 @@
|
||||
# API
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# Solana
|
||||
NEXT_PUBLIC_SOLANA_NETWORK=devnet
|
||||
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_NAME=CoopCredits
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
@@ -0,0 +1,38 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build Next.js app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start application
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/webhooks/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL}/webhooks/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "coop-credits-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solana/wallet-adapter-base": "^0.9.23",
|
||||
"@solana/wallet-adapter-react": "^0.15.35",
|
||||
"@solana/wallet-adapter-react-ui": "^0.9.34",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@solana/wallet-adapter-wallets": "^0.19.32",
|
||||
"@solana/web3.js": "^1.87.6",
|
||||
"axios": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"recharts": "^2.10.3",
|
||||
"socket.io-client": "^4.7.3",
|
||||
"sonner": "^1.2.4",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Users,
|
||||
Settings,
|
||||
Pause,
|
||||
Play,
|
||||
TrendingUp,
|
||||
Search,
|
||||
Gift
|
||||
} from 'lucide-react';
|
||||
import { formatNumber } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Analytics {
|
||||
users: {
|
||||
total: number;
|
||||
with_wallet: number;
|
||||
new_this_week: number;
|
||||
};
|
||||
transactions: {
|
||||
total_earned: number;
|
||||
total_spent: number;
|
||||
total_transactions: number;
|
||||
};
|
||||
watchStats: {
|
||||
total_seconds: number;
|
||||
total_credits: number;
|
||||
total_events: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
walletAddress: string | null;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
watchTimeMinutes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated } = useStore();
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user?.isAdmin) {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [isAuthenticated, user, router]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [analyticsRes, usersRes, settingsRes] = await Promise.all([
|
||||
adminApi.getAnalytics(),
|
||||
adminApi.getUsers(),
|
||||
adminApi.getSettings()
|
||||
]);
|
||||
|
||||
setAnalytics(analyticsRes.data);
|
||||
setUsers(usersRes.data.users);
|
||||
setSettings(settingsRes.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load admin data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await adminApi.pause();
|
||||
toast.success('Minting paused');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to pause minting');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
try {
|
||||
await adminApi.resume();
|
||||
toast.success('Minting resumed');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to resume minting');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGrantBonus = async (userId: string) => {
|
||||
const amount = prompt('Enter bonus amount:');
|
||||
if (!amount) return;
|
||||
|
||||
try {
|
||||
await adminApi.grantBonus(userId, parseInt(amount), 'Admin bonus');
|
||||
toast.success('Bonus granted');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to grant bonus');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated || !user?.isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b bg-card">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<Badge variant="secondary">{user.plexUsername}</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => router.push('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{analytics?.users.total || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{analytics?.users.with_wallet || 0} with wallets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Minted</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(analytics?.transactions.total_earned || 0)} $COOP
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{analytics?.transactions.total_transactions || 0} transactions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Watch Time</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{Math.floor((analytics?.watchStats.total_seconds || 0) / 3600)}h
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{analytics?.watchStats.total_events || 0} watch events
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${settings?.mintingPaused ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<span className="font-bold">
|
||||
{settings?.mintingPaused ? 'Paused' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="sm" variant="outline" onClick={handlePause} disabled={settings?.mintingPaused}>
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleResume} disabled={!settings?.mintingPaused}>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="users">
|
||||
<TabsList>
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className="font-medium">{u.plexUsername}</p>
|
||||
<p className="text-sm text-muted-foreground">{u.email}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{u.isAdmin && <Badge variant="default">Admin</Badge>}
|
||||
{!u.isActive && <Badge variant="destructive">Inactive</Badge>}
|
||||
{u.walletAddress && <Badge variant="secondary">Wallet</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-mono text-sm">
|
||||
{formatNumber(u.totalEarned - u.totalSpent)} $COOP
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleGrantBonus(u.id)}
|
||||
>
|
||||
<Gift className="h-4 w-4 mr-1" />
|
||||
Bonus
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Minting Settings</CardTitle>
|
||||
<CardDescription>Configure how users earn $COOP</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Credits Per Minute</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings?.creditsPerMinute || 10}
|
||||
onChange={(e) => setSettings({ ...settings, creditsPerMinute: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Min Watch Percent</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings?.minWatchPercent || 80}
|
||||
onChange={(e) => setSettings({ ...settings, minWatchPercent: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Movie Request Cost</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings?.movieRequestCost || 100}
|
||||
onChange={(e) => setSettings({ ...settings, movieRequestCost: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>TV Request Cost</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings?.tvRequestCost || 200}
|
||||
onChange={(e) => setSettings({ ...settings, tvRequestCost: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => adminApi.updateSettings(settings).then(() => toast.success('Settings saved'))}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { walletApi } from '@/lib/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Copy, Check, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateWalletModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateWalletModal({ open, onClose, onCreated }: CreateWalletModalProps) {
|
||||
const [step, setStep] = useState<'create' | 'backup' | 'success'>('create');
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await walletApi.createWallet();
|
||||
setPrivateKey(response.data.privateKey || '');
|
||||
setStep('backup');
|
||||
toast.success('Wallet created successfully!');
|
||||
onCreated();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create wallet');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
const response = await walletApi.backupWallet();
|
||||
setPrivateKey(response.data.privateKey);
|
||||
setStep('backup');
|
||||
} catch (error) {
|
||||
toast.error('Failed to get backup');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(privateKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStep('create');
|
||||
setPrivateKey('');
|
||||
setCopied(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'create' && 'Create Wallet'}
|
||||
{step === 'backup' && 'Backup Your Wallet'}
|
||||
{step === 'success' && 'Wallet Ready!'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'create' && 'Create a new Solana wallet to store your $COOP tokens'}
|
||||
{step === 'backup' && 'Save this private key securely. You will need it to recover your wallet.'}
|
||||
{step === 'success' && 'Your wallet is ready to use!'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'create' && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You will be shown a private key. Store it securely - it cannot be recovered!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={handleCreate} disabled={isLoading} className="w-full">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create New Wallet'
|
||||
)}
|
||||
</Button>
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-muted-foreground">or</span>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleBackup} className="w-full">
|
||||
Show Existing Backup
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'backup' && (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Never share this private key with anyone. Store it in a secure password manager.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label>Private Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={privateKey}
|
||||
readOnly
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setStep('success')} className="w-full">
|
||||
I have saved my private key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Your wallet has been created and funded with 2 SOL for transaction fees.
|
||||
Start watching content on Plex to earn $COOP!
|
||||
</p>
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Start Earning
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { transactionApi } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatNumber, formatDate } from '@/lib/utils';
|
||||
import { TrendingUp, TrendingDown, Gift, ArrowRightLeft } from 'lucide-react';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT';
|
||||
amount: number;
|
||||
description: string | null;
|
||||
contentTitle: string | null;
|
||||
createdAt: string;
|
||||
solanaSignature: string | null;
|
||||
}
|
||||
|
||||
export function TransactionList() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions();
|
||||
}, []);
|
||||
|
||||
const loadTransactions = async () => {
|
||||
try {
|
||||
const response = await transactionApi.getTransactions();
|
||||
setTransactions(response.data.transactions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'EARN':
|
||||
return <TrendingUp className="h-4 w-4 text-green-500" />;
|
||||
case 'SPEND':
|
||||
return <TrendingDown className="h-4 w-4 text-red-500" />;
|
||||
case 'BONUS':
|
||||
return <Gift className="h-4 w-4 text-purple-500" />;
|
||||
default:
|
||||
return <ArrowRightLeft className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'EARN':
|
||||
return 'bg-green-500/10 text-green-500';
|
||||
case 'SPEND':
|
||||
return 'bg-red-500/10 text-red-500';
|
||||
case 'BONUS':
|
||||
return 'bg-purple-500/10 text-purple-500';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Loading transactions...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No transactions yet. Start watching content to earn $COOP!
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2 rounded-full ${getTransactionColor(tx.type)}`}>
|
||||
{getTransactionIcon(tx.type)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{tx.contentTitle || tx.description || tx.type}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(tx.createdAt)}
|
||||
</p>
|
||||
{tx.solanaSignature && (
|
||||
<a
|
||||
href={`https://explorer.solana.com/tx/${tx.solanaSignature}?cluster=devnet`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
View on Explorer
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-bold ${
|
||||
tx.type === 'EARN' || tx.type === 'BONUS'
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'
|
||||
}`}>
|
||||
{tx.type === 'EARN' || tx.type === 'BONUS' ? '+' : '-'}
|
||||
{formatNumber(tx.amount)} $COOP
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tx.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { userApi } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDuration, formatDate, formatNumber } from '@/lib/utils';
|
||||
import { Film, Tv, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface WatchEvent {
|
||||
id: string;
|
||||
contentType: string;
|
||||
title: string;
|
||||
grandparentTitle: string | null;
|
||||
duration: number;
|
||||
percentComplete: number;
|
||||
creditsEarned: number;
|
||||
isProcessed: boolean;
|
||||
watchedAt: string;
|
||||
}
|
||||
|
||||
export function WatchHistory() {
|
||||
const [events, setEvents] = useState<WatchEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, []);
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const response = await userApi.getWatchHistory();
|
||||
setEvents(response.data.events);
|
||||
} catch (error) {
|
||||
console.error('Failed to load watch history:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getContentIcon = (type: string) => {
|
||||
return type === 'movie' ? (
|
||||
<Film className="h-4 w-4" />
|
||||
) : (
|
||||
<Tv className="h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Loading watch history...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No watch history yet. Start watching on Plex!
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Watch History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||
{getContentIcon(event.contentType)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{event.title}</p>
|
||||
{event.grandparentTitle && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{event.grandparentTitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(event.duration)}
|
||||
</span>
|
||||
<span>{event.percentComplete}% watched</span>
|
||||
<span>{formatDate(event.watchedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{event.isProcessed ? (
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="font-bold">
|
||||
+{formatNumber(event.creditsEarned)} $COOP
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-yellow-500">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="text-sm">Pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { walletApi, transactionApi, overseerApi } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
Film,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
History
|
||||
} from 'lucide-react';
|
||||
import { formatNumber, truncateAddress } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { TransactionList } from './components/TransactionList';
|
||||
import { WatchHistory } from './components/WatchHistory';
|
||||
import { CreateWalletModal } from './components/CreateWalletModal';
|
||||
|
||||
interface WalletData {
|
||||
hasWallet: boolean;
|
||||
address?: string;
|
||||
balance: number;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
explorerUrl?: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, logout } = useStore();
|
||||
const [wallet, setWallet] = useState<WalletData | null>(null);
|
||||
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 });
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [walletRes, costsRes] = await Promise.all([
|
||||
walletApi.getWallet(),
|
||||
overseerApi.getCosts().catch(() => ({ data: { movie: 100, tv: 200 } }))
|
||||
]);
|
||||
|
||||
setWallet(walletRes.data);
|
||||
setRequestCosts(costsRes.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load wallet data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-card">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">CoopCredits</h1>
|
||||
<Badge variant="secondary">{user.plexUsername}</Badge>
|
||||
{user.isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => router.push('/admin')}>
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={logout}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Balance</CardTitle>
|
||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{wallet ? formatNumber(wallet.balance) : '--'} $COOP
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{wallet?.hasWallet ? 'Ready to spend' : 'Create wallet to start'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Earned</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{wallet ? formatNumber(wallet.totalEarned) : '--'} $COOP
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From watching content
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{wallet ? formatNumber(wallet.totalSpent) : '--'} $COOP
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
On content requests
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">Request Cost</CardTitle>
|
||||
<Film className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{requestCosts.movie} $COOP</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Per movie request
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Wallet Section */}
|
||||
{!wallet?.hasWallet && (
|
||||
<Card className="mb-8 border-dashed border-2">
|
||||
<CardContent className="py-8 text-center">
|
||||
<Wallet className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Wallet Connected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create a Solana wallet to start earning and spending $COOP tokens
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Wallet
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{wallet?.hasWallet && (
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
Your Wallet
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your Solana wallet and $COOP tokens
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Address</p>
|
||||
<p className="font-mono font-medium">
|
||||
{truncateAddress(wallet.address!)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsCreateModalOpen(true)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Backup
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={wallet.explorerUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Explorer
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="transactions" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="transactions">Transactions</TabsTrigger>
|
||||
<TabsTrigger value="history">Watch History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="transactions">
|
||||
<TransactionList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<WatchHistory />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
|
||||
<CreateWalletModal
|
||||
open={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/components/providers';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CoopCredits - Media Rewards',
|
||||
description: 'Earn $COOP tokens for watching content on Plex',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, Tv } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser, setToken, isAuthenticated } = useStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for Plex OAuth callback
|
||||
const code = searchParams.get('code');
|
||||
if (code) {
|
||||
handlePlexCallback(code);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const handlePlexCallback = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.plexCallback(code);
|
||||
const { user, token } = response.data;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
setUser(user);
|
||||
setToken(token);
|
||||
|
||||
toast.success(`Welcome, ${user.plexUsername}!`);
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
toast.error('Authentication failed');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlexLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.getPlexUrl();
|
||||
window.location.href = response.data.authUrl;
|
||||
} catch (error) {
|
||||
toast.error('Failed to initiate Plex login');
|
||||
console.error(error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Tv className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">CoopCredits</CardTitle>
|
||||
<CardDescription>
|
||||
Earn $COOP tokens for watching content on Plex
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handlePlexLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tv className="mr-2 h-5 w-5" />
|
||||
Sign in with Plex
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Sign in with your Plex account to start earning rewards
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { authApi } from '@/lib/api';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { setUser, setToken } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Check for stored token on mount
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// Verify token and get user data
|
||||
authApi.verify()
|
||||
.then((res) => {
|
||||
setUser(res.data.user);
|
||||
setToken(token);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
});
|
||||
}
|
||||
}, [setUser, setToken]);
|
||||
|
||||
if (!mounted) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,98 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle token expiration
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
getPlexUrl: () => api.get('/auth/plex/url'),
|
||||
plexCallback: (code: string) => api.post('/auth/plex/callback', { code }),
|
||||
verify: () => api.get('/auth/verify'),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
};
|
||||
|
||||
// User API
|
||||
export const userApi = {
|
||||
getMe: () => api.get('/users/me'),
|
||||
updateMe: (data: { email?: string }) => api.put('/users/me', data),
|
||||
getWatchHistory: (page = 1, limit = 20) =>
|
||||
api.get(`/users/me/watch-history?page=${page}&limit=${limit}`),
|
||||
getRequests: (page = 1, limit = 20, status?: string) =>
|
||||
api.get(`/users/me/requests?page=${page}&limit=${limit}${status ? `&status=${status}` : ''}`),
|
||||
};
|
||||
|
||||
// Wallet API
|
||||
export const walletApi = {
|
||||
getWallet: () => api.get('/wallet'),
|
||||
createWallet: () => api.post('/wallet/create'),
|
||||
connectWallet: (address: string) => api.post('/wallet/connect', { address }),
|
||||
backupWallet: () => api.post('/wallet/backup'),
|
||||
};
|
||||
|
||||
// Transactions API
|
||||
export const transactionApi = {
|
||||
getTransactions: (page = 1, limit = 20, type?: string) =>
|
||||
api.get(`/transactions?page=${page}&limit=${limit}${type ? `&type=${type}` : ''}`),
|
||||
getStats: () => api.get('/transactions/stats'),
|
||||
};
|
||||
|
||||
// Admin API
|
||||
export const adminApi = {
|
||||
getSettings: () => api.get('/admin/settings'),
|
||||
updateSettings: (data: any) => api.put('/admin/settings', data),
|
||||
pause: () => api.post('/admin/pause'),
|
||||
resume: () => api.post('/admin/resume'),
|
||||
getUsers: (page = 1, limit = 50, search?: string) =>
|
||||
api.get(`/admin/users?page=${page}&limit=${limit}${search ? `&search=${search}` : ''}`),
|
||||
updateUser: (id: string, data: any) => api.put(`/admin/users/${id}`, data),
|
||||
grantBonus: (userId: string, amount: number, reason?: string) =>
|
||||
api.post(`/admin/users/${userId}/bonus`, { amount, reason }),
|
||||
getAnalytics: () => api.get('/admin/analytics'),
|
||||
getAllTransactions: (page = 1, limit = 50) =>
|
||||
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
||||
getSystemStats: () => api.get('/transactions/admin/stats'),
|
||||
};
|
||||
|
||||
// Overseer API
|
||||
export const overseerApi = {
|
||||
getCosts: () => api.get('/overseer/costs'),
|
||||
getBalance: () => api.get('/overseer/balance'),
|
||||
search: (query: string) => api.get(`/overseer/search?query=${encodeURIComponent(query)}`),
|
||||
request: (data: { mediaType: string; mediaId: number; title: string; seasons?: number[] }) =>
|
||||
api.post('/overseer/request', data),
|
||||
};
|
||||
|
||||
// Tautulli API
|
||||
export const tautulliApi = {
|
||||
getStatus: () => api.get('/tautulli/status'),
|
||||
getStats: () => api.get('/tautulli/stats'),
|
||||
getWebhookConfig: () => api.get('/tautulli/webhook-config'),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
plexId: string;
|
||||
plexUsername: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
walletAddress: string | null;
|
||||
totalEarned: number;
|
||||
totalSpent: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setUser: (user: User | null) => void;
|
||||
setToken: (token: string | null) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
setToken: (token) => set({ token }),
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'coop-credits-storage',
|
||||
partialize: (state) => ({ user: state.user, token: state.token }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
export function truncateAddress(address: string, chars = 4): string {
|
||||
if (!address) return '';
|
||||
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"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,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "coop-credits",
|
||||
"version": "1.0.0",
|
||||
"description": "CoopCredits - Solana-based media rewards ecosystem",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"install:all": "npm install && cd backend && npm install && cd ../frontend && npm install && cd ../anchor-program && npm install",
|
||||
"build": "cd backend && npm run build && cd ../frontend && npm run build",
|
||||
"db:migrate": "cd backend && npx prisma migrate dev",
|
||||
"db:generate": "cd backend && npx prisma generate",
|
||||
"db:studio": "cd backend && npx prisma studio",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f",
|
||||
"docker:prod:up": "docker-compose -f docker-compose.prod.yml up -d",
|
||||
"docker:prod:down": "docker-compose -f docker-compose.prod.yml down",
|
||||
"docker:prod:logs": "docker-compose -f docker-compose.prod.yml logs -f",
|
||||
"deploy": "./deployment/deploy.sh",
|
||||
"deploy:prod": "./deployment/deploy-production.sh",
|
||||
"setup:solana": "./deployment/setup-solana.sh",
|
||||
"setup:infra": "./deployment/setup-infrastructure.sh",
|
||||
"health": "./deployment/health-check.sh",
|
||||
"lint": "cd backend && npm run lint && cd ../frontend && npm run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend",
|
||||
"anchor-program"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user