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:
2026-04-14 11:09:50 -04:00
parent f106328f6a
commit e8d9b1fd42
69 changed files with 11382 additions and 0 deletions
+74
View File
@@ -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
+168
View File
@@ -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`
+81
View File
@@ -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.
+195
View File
@@ -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`
+27
View File
@@ -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"
+3596
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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
+28
View File
@@ -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,
}
+95
View File
@@ -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;
});
});
+38
View File
@@ -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!!
+45
View File
@@ -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"]
+49
View File
@@ -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"
}
}
+189
View File
@@ -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
}
+126
View File
@@ -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 };
+61
View File
@@ -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();
};
+32
View File
@@ -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);
};
};
+308
View File
@@ -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 };
+186
View File
@@ -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 };
+217
View File
@@ -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 };
+93
View File
@@ -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 };
+210
View File
@@ -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 };
+135
View File
@@ -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 };
+193
View File
@@ -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 };
+204
View File
@@ -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 };
+87
View File
@@ -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);
}
+220
View File
@@ -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;
}
}
+11
View File
@@ -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;
+30
View File
@@ -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"]
}
+258
View File
@@ -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
+132
View File
@@ -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 "$@"
+228
View File
@@ -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
+357
View File
@@ -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 "$@"
+85
View File
@@ -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!"
+220
View File
@@ -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
+109
View File
@@ -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
+119
View File
@@ -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;
}
}
}
+206
View File
@@ -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;
}
}
}
+232
View File
@@ -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
+372
View File
@@ -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`
+10
View File
@@ -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
+38
View File
@@ -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"]
+20
View File
@@ -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;
+44
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+333
View File
@@ -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>
);
}
+232
View File
@@ -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>
);
}
+59
View File
@@ -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;
}
}
+29
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+58
View File
@@ -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 };
+35
View File
@@ -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 };
+55
View File
@@ -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 };
+78
View File
@@ -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 };
+121
View File
@@ -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,
};
+24
View File
@@ -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 };
+25
View File
@@ -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 };
+54
View File
@@ -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 };
+98
View File
@@ -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'),
};
+42
View File
@@ -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 }),
}
)
);
+35
View File
@@ -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)}`;
}
+57
View File
@@ -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;
+27
View File
@@ -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"]
}
+36
View File
@@ -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"
]
}