78 Commits

Author SHA1 Message Date
syntaxbullet
fee4969910 feat: configure dedicated bot SSH key and non-interactive SSH for git operations.
Some checks failed
Deploy to Production / test (push) Failing after 20s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 15:26:07 +01:00
syntaxbullet
dabcb4cab3 feat: Mount SSH keys for Git authentication and disable interactive prompts in the update service. 2026-01-30 15:21:41 +01:00
syntaxbullet
1a3f5c6654 feat: Introduce scripts for database backup, restore, and log viewing, replacing remote dashboard and studio scripts.
Some checks failed
Deploy to Production / test (push) Failing after 22s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 15:15:22 +01:00
syntaxbullet
422db6479b feat: Store update restart context in the deployment directory and configure Docker to use the default bun user.
Some checks failed
Deploy to Production / test (push) Failing after 24s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 15:06:32 +01:00
syntaxbullet
35ecea16f7 feat: Enable Git operations within a specified deployment directory by adding cwd options and configuring Git to trust the deploy directory. 2026-01-30 14:56:29 +01:00
syntaxbullet
9ff679ee5c feat: Introduce Docker socket proxy and install Docker CLI in the app container for secure deployment operations.
Some checks failed
Deploy to Production / test (push) Failing after 24s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 14:46:06 +01:00
syntaxbullet
ebefd8c0df feat: add bot-triggered deployment via /update deploy command
Some checks failed
Deploy to Production / test (push) Failing after 20s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
- Added Docker socket mount to docker-compose.prod.yml
- Added project directory mount for git operations
- Added performDeploy, isDeployAvailable methods to UpdateService
- Added /update deploy subcommand for Discord-triggered deployments
- Added deploy-related embeds to update.view.ts
2026-01-30 14:26:38 +01:00
syntaxbullet
73531f38ae docs: clarify update command behavior in production Docker environment
Some checks failed
Deploy to Production / test (push) Failing after 21s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 14:18:45 +01:00
syntaxbullet
5a6356d271 fix: include web/src in production Dockerfile for direct TS imports
Some checks failed
Deploy to Production / test (push) Failing after 22s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 14:15:30 +01:00
syntaxbullet
f9dafeac3b Merge branch 'main' of https://git.ayau.me/syntaxbullet/discord-rpg-concept
Some checks failed
Deploy to Production / test (push) Failing after 1m27s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 13:44:04 +01:00
syntaxbullet
1a2bbb011c feat: Introduce production Docker and CI/CD setup, removing internal documentation and agent workflows. 2026-01-30 13:43:59 +01:00
syntaxbullet
2ead35789d fix: prevent studio service from inheriting port 3000 from app 2026-01-23 13:58:37 +01:00
syntaxbullet
c1da71227d chore: update cleanup scripts 2026-01-23 13:47:48 +01:00
syntaxbullet
17e636c4e5 feat: Overhaul Docker infrastructure with multi-stage builds, add a cleanup script, and refactor the update service to combine update and requirement checks. 2026-01-17 16:20:33 +01:00
syntaxbullet
d7543d9f48 feat: (web) add item route 2026-01-17 13:11:50 +01:00
syntaxbullet
afe82c449b feat: add web asset rebuilding to update command and consolidate post-restart messages
- Detect web/src/** changes and trigger frontend rebuild after updates
- Add buildWebAssets flag to RestartContext and needsWebBuild to UpdateCheckResult
- Consolidate post-restart progress into single editable message
- Delete progress message after completion, show only final result
2026-01-16 16:37:11 +01:00
syntaxbullet
3c1334b30e fix: update sub-navigation item colors for active, hover, and default states 2026-01-16 16:27:23 +01:00
syntaxbullet
58f261562a feat: Implement an admin quest management table, enhance toast notifications with descriptions, and add new agent documentation. 2026-01-16 15:58:48 +01:00
syntaxbullet
4ecbffd617 refactor: replace hardcoded SVGs with lucide-react icons in quest-table 2026-01-16 15:27:15 +01:00
syntaxbullet
5491551544 fix: (web) prevent flickering during refresh
- Track isInitialLoading separately from isRefreshing
- Only show skeleton on initial page load (when quests is empty)
- During refresh, keep existing content visible
- Spinning refresh icon indicates refresh in progress without clearing table
2026-01-16 15:22:28 +01:00
syntaxbullet
7d658bbef9 fix: (web) fix refresh icon spinning indefinitely
- Remove redundant isRefreshing state
- Icon spin is controlled by isLoading prop from parent
- Parent correctly manages loading state during fetch
2026-01-16 15:20:36 +01:00
syntaxbullet
d117bcb697 fix: (web) restore quest table loading logic
- Simplify component by removing complex state management
- Show skeleton only during initial load, content otherwise
- Keep refresh icon spin during manual refresh
2026-01-16 15:18:51 +01:00
syntaxbullet
94e332ba57 fix: (web) improve quest table refresh UX
- Keep card visible during refresh to prevent flicker
- Add smooth animations when content loads
- Spin refresh icon independently from skeleton
- Show skeleton in place without replacing entire card
2026-01-16 15:16:48 +01:00
syntaxbullet
3ef9773990 feat: (web) add quest table component for admin quests page
- Add getAllQuests() method to quest.service.ts
- Add GET /api/quests endpoint to server.ts
- Create QuestTable component with data display, formatting, and states
- Update AdminQuests.tsx to fetch and display quests above the form
- Add onSuccess callback to QuestForm for refresh handling
2026-01-16 15:12:41 +01:00
syntaxbullet
d243a11bd3 feat: (docs) add main.md 2026-01-16 13:34:35 +01:00
syntaxbullet
47ce0f12e6 chore: remove old documentation. 2026-01-16 13:18:54 +01:00
syntaxbullet
f2caa1a3ee chore: replace tw-gradient classes with canonical shortened -linear classnames 2026-01-16 12:59:32 +01:00
syntaxbullet
2a72beb0ef feat: Implement new settings pages and refactor application layout and navigation with new components and hooks. 2026-01-16 12:49:17 +01:00
syntaxbullet
2f73f38877 feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components. 2026-01-15 17:21:49 +01:00
syntaxbullet
9e5c6b5ac3 feat: Implement interactive quest command allowing users to view active/available quests and accept new ones. 2026-01-15 15:30:01 +01:00
syntaxbullet
eb108695d3 feat: Implement flexible quest event matching to allow generic triggers to match specific event instances. 2026-01-15 15:22:20 +01:00
syntaxbullet
7d541825d8 feat: Update quest event triggers to include item IDs for granular tracking. 2026-01-15 15:09:37 +01:00
syntaxbullet
52f8ab11f0 feat: Implement quest event handling and integrate it into leveling, economy, and inventory services. 2026-01-15 15:04:50 +01:00
syntaxbullet
f8436e9755 chore: (agent) remove tickets and skills 2026-01-15 11:13:37 +01:00
syntaxbullet
194a032c7f chore(cleanup): remove completed tickets 2026-01-14 18:10:31 +01:00
syntaxbullet
94a5a183d0 feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage 2026-01-14 18:10:13 +01:00
syntaxbullet
c7730b9355 refactor: migrate web server to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
1e20a5a7a0 refactor: migrate bot handlers to centralized logger 2026-01-14 17:58:28 +01:00
syntaxbullet
54944283a3 feat: implement centralized logger with file persistence 2026-01-14 17:58:28 +01:00
syntaxbullet
f79ee6fbc7 refactor: remove completed ticket file 2026-01-14 16:27:49 +01:00
syntaxbullet
915f1bc4ad fix(economy): improve daily cooldown message and consolidate UserError class 2026-01-14 16:26:27 +01:00
syntaxbullet
4af2690bab feat: implement branded discord embeds and versioning 2026-01-14 16:10:23 +01:00
syntaxbullet
6e57ab07e4 chore: update gitiignore 2026-01-14 15:12:51 +01:00
syntaxbullet
3a620a84c5 feat: add trivia category selection and sync trivia fixes 2026-01-11 16:08:11 +01:00
syntaxbullet
7d68652ea5 fix: fix potential issues with trivia command 2026-01-11 15:00:10 +01:00
syntaxbullet
35bd1f58dd feat: trivia command! 2026-01-11 14:37:17 +01:00
syntaxbullet
1cd3dbcd72 agent: update agent workflows 2026-01-09 22:04:40 +01:00
syntaxbullet
c97249f2ca docs: update README with dashboard architecture and ssh tunnel guide 2026-01-09 22:02:09 +01:00
syntaxbullet
0d923491b5 feat: (ui) settings drawers 2026-01-09 19:28:14 +01:00
syntaxbullet
d870ef69d5 feat: (ui) leaderboards 2026-01-09 16:45:36 +01:00
syntaxbullet
682e9d208e feat: more stat components 2026-01-09 16:18:52 +01:00
syntaxbullet
4a691ac71d feat: (ui) first dynamic data 2026-01-09 15:22:13 +01:00
syntaxbullet
1b84dbd36d feat: (ui) new design 2026-01-09 15:12:35 +01:00
syntaxbullet
a5b8d922e3 feat(web): implement full activity page with charts and logs 2026-01-08 23:20:00 +01:00
syntaxbullet
238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00
syntaxbullet
713ea07040 feat(ui): use shadcn switch for toggles and remove sidebar user footer 2026-01-08 23:00:44 +01:00
syntaxbullet
bea6c33024 feat(settings): group commands by category in system tab 2026-01-08 22:55:40 +01:00
syntaxbullet
8fe300c8a2 feat(web): add toast notifications for settings save status 2026-01-08 22:47:31 +01:00
syntaxbullet
9caa95a0d8 feat(settings): support toggling disabled commands and auto-reload bot on save 2026-01-08 22:44:48 +01:00
syntaxbullet
c6fd23b5fa feat(dashboard): implement bot settings page with partial updates and serialization fixes 2026-01-08 22:35:46 +01:00
syntaxbullet
d46434de18 feat(dashboard): expand stats & remove admin token auth 2026-01-08 22:14:13 +01:00
syntaxbullet
cf4c28e1df fix : 404 error fix 2026-01-08 21:45:53 +01:00
syntaxbullet
39e405afde chore: polish analytics API logging and typing 2026-01-08 21:39:53 +01:00
syntaxbullet
6763e3c543 fix: address code review findings for analytics and security 2026-01-08 21:39:01 +01:00
syntaxbullet
11e07a0068 feat: implement visual analytics and activity charts 2026-01-08 21:36:19 +01:00
syntaxbullet
5d2d4bb0c6 refactor: improve type safety and remove forced casts in dashboard service 2026-01-08 21:31:40 +01:00
syntaxbullet
19206b5cc7 fix: address security review findings, implement real cache clearing, and fix lifecycle promises 2026-01-08 21:29:09 +01:00
syntaxbullet
0f6cce9b6e feat: implement administrative control panel with real-time bot actions 2026-01-08 21:19:16 +01:00
syntaxbullet
3f3a6c88e8 fix(dash): resolve test regressions, await promises, and improve TypeScript strictness 2026-01-08 21:12:41 +01:00
syntaxbullet
8253de9f73 fix(dash): address safety constraints, validation, and test quality issues 2026-01-08 21:08:47 +01:00
syntaxbullet
1251df286e feat: implement real-time dashboard updates via WebSockets 2026-01-08 21:01:33 +01:00
syntaxbullet
fff90804c0 feat(dash): Revamp dashboard UI with glassmorphism and real bot data 2026-01-08 20:58:57 +01:00
syntaxbullet
8ebaf7b4ee docs: update ticket status to In Review with implementation notes 2026-01-08 18:51:58 +01:00
syntaxbullet
17cb70ec00 feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
2026-01-08 18:50:44 +01:00
syntaxbullet
a207d511be docs: clarify drizzle studio access via proxy URL 2026-01-08 18:20:27 +01:00
syntaxbullet
cf4f180124 fix: add web network to studio for port publishing 2026-01-08 18:17:27 +01:00
syntaxbullet
5df1396b3f chore: update docker compose 2026-01-08 18:12:39 +01:00
syntaxbullet
daad7be01c chore: attempt fixing drizzle studio 2026-01-08 18:04:40 +01:00
149 changed files with 12595 additions and 1597 deletions

View File

@@ -1,57 +0,0 @@
---
description: Create a new Ticket
---
### Role
You are a Senior Technical Product Manager and Lead Engineer. Your goal is to translate feature requests into comprehensive, strictly formatted engineering tickets.
### Task
When I ask you to "scope a feature" or "create a ticket" for a specific functionality:
1. Analyze the request for technical implications, edge cases, and architectural fit.
2. Generate a new Markdown file.
3. Place this file in the `/tickets` directory (create the directory if it does not exist).
### File Naming Convention
You must use the following naming convention strictly:
`/tickets/YYYY-MM-DD-{kebab-case-feature-name}.md`
*Example:* `/tickets/2024-10-12-user-authentication-flow.md`
### File Content Structure
The markdown file must adhere to the following template exactly. Do not skip sections. If a section is not applicable, write "N/A" but explain why.
```markdown
# [Ticket ID]: [Feature Title]
**Status:** Draft
**Created:** [YYYY-MM-DD]
**Tags:** [comma, separated, tags]
## 1. Context & User Story
* **As a:** [Role]
* **I want to:** [Action]
* **So that:** [Benefit/Value]
## 2. Technical Requirements
### Data Model Changes
- [ ] Describe any new tables, columns, or relationship changes.
- [ ] SQL migration required? (Yes/No)
### API / Interface
- [ ] Define endpoints (method, path) or function signatures.
- [ ] Payload definition (JSON structure or Types).
## 3. Constraints & Validations (CRITICAL)
*This section must be exhaustive. Do not be vague.*
- **Input Validation:** (e.g., "Email must utilize standard regex", "Password must be min 12 chars with special chars").
- **System Constraints:** (e.g., "Image upload max size 5MB", "Request timeout 30s").
- **Business Logic Guardrails:** (e.g., "User cannot upgrade if balance < $0").
## 4. Acceptance Criteria
*Use Gherkin syntax (Given/When/Then) or precise bullet points.*
1. [ ] Criteria 1
2. [ ] Criteria 2
## 5. Implementation Plan
- [ ] Step 1: ...
- [ ] Step 2: ...

View File

@@ -1,53 +0,0 @@
---
description: Review the most recent changes critically.
---
### Role
You are a Lead Security Engineer and Senior QA Automator. Your persona is **"The Hostile Reviewer."**
* **Mindset:** You do not trust the code. You assume it contains bugs, security flaws, and logic gaps.
* **Goal:** Your objective is to reject the most recent git changes by finding legitimate issues. If you cannot find issues, only then do you approve.
### Phase 1: The Security & Logic Audit
Analyze the code changes for specific vulnerabilities. Do not summarize what the code does; look for what it *does wrong*.
1. **TypeScript Strictness:**
* Flag any usage of `any`.
* Flag any use of non-null assertions (`!`) unless strictly guarded.
* Flag forced type casting (`as UnknownType`) without validation.
2. **Bun/Runtime Specifics:**
* Check for unhandled Promises (floating promises).
* Ensure environment variables are not hardcoded.
3. **Security Vectors:**
* **Injection:** Check SQL/NoSQL queries for concatenation.
* **Sanitization:** Are inputs from the generic request body validated against the schema defined in the Ticket?
* **Auth:** Are sensitive routes actually protected by middleware?
### Phase 2: Test Quality Verification
Do not just check if tests pass. Check if the tests are **valid**.
1. **The "Happy Path" Trap:** If the tests only check for success (status 200), **FAIL** the review.
2. **Edge Case Coverage:**
* Did the code handle the *Constraints & Validations* listed in the original ticket?
* *Example:* If the ticket says "Max 5MB upload", is there a test case for a 5.1MB file?
3. **Mocking Integrity:** Are mocks too permissive? (e.g., Mocking a function to always return `true` regardless of input).
### Phase 3: The Verdict
Output your review in the following strict format:
---
# 🛡️ Code Review Report
**Ticket ID:** [Ticket Name]
**Verdict:** [🔴 REJECT / 🟢 APPROVE]
## 🚨 Critical Issues (Must Fix)
*List logic bugs, security risks, or failing tests.*
1. ...
2. ...
## ⚠️ Suggestions (Refactoring)
*List code style improvements, variable naming, or DRY opportunities.*
1. ...
## 🧪 Test Coverage Gap Analysis
*List specific scenarios that are NOT currently tested but should be.*
- [ ] Scenario: ...

View File

@@ -1,50 +0,0 @@
---
description: Pick a Ticket and work on it.
---
### Role
You are an Autonomous Senior Software Engineer specializing in TypeScript and Bun. You are responsible for the full lifecycle of feature implementation: selection, coding, testing, verification, and closure.
### Phase 1: Triage & Selection
1. **Scan:** Read all files in the `/tickets` directory.
2. **Filter:** Ignore tickets marked `Status: Done` or `Status: Archived`.
3. **Prioritize:** Select a single ticket based on the following hierarchy:
* **Tags:** `Critical` > `High Priority` > `Bug` > `Feature`.
* **Age:** Oldest created date first (FIFO).
4. **Announce:** Explicitly state: "I am picking ticket: [Ticket ID/Name] because [Reason]."
### Phase 2: Setup (Non-Destructive)
1. **Branching:** Create a new git branch based on the ticket name.
* *Format:* `feat/{ticket-kebab-name}` or `fix/{ticket-kebab-name}`.
* *Command:* `git checkout -b feat/user-auth-flow`.
2. **Context:** Read the selected ticket markdown file thoroughly, paying special attention to "Constraints & Validations."
### Phase 3: Implementation & Testing (The Loop)
*Iterate until the requirements are met.*
1. **Write Code:** Implement the feature or fix using TypeScript.
2. **Tightened Testing:**
* You must create or update test files (`*.test.ts` or `*.spec.ts`).
* **Requirement:** Tests must cover happy paths AND the edge cases defined in the ticket's "Constraints" section.
* *Mocking:* Mock external dependencies where appropriate to ensure isolation.
3. **Type Safety Check:**
* Run: `bun x tsc --noEmit`
* **CRITICAL:** If there are ANY TypeScript errors, you must fix them immediately. Do not proceed.
4. **Runtime Verification:**
* Run: `bun test`
* Ensure all tests pass. If a test fails, analyze the stack trace, fix the implementation, and rerun.
### Phase 4: Self-Review & Clean Up
Before declaring the task finished, perform a self-review:
1. **Linting:** Check for unused variables, any types, or console logs.
2. **Refactor:** Ensure code is DRY (Don't Repeat Yourself) and strictly typed.
3. **Ticket Update:**
* Modify the Markdown ticket file.
* Change `Status: Draft` to `Status: In Review` or `Status: Done`.
* Add a new section at the bottom: `## Implementation Notes` listing the specific files changed.
### Phase 5: Handover
Only when `bun x tsc` and `bun test` pass with 0 errors:
1. Commit the changes with a semantic message (e.g., `feat: implement user auth logic`).
2. Present a summary of the work done and ask for a human code review.

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies - handled inside container
node_modules
web/node_modules
# Git
.git
.gitignore
# Logs and data
logs
*.log
shared/db/data
shared/db/log
# Development tools
.env
.env.example
.opencode
.agent
# Documentation
docs
*.md
!README.md
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build artifacts
dist
.cache
*.tsbuildinfo

View File

@@ -1,12 +1,26 @@
# =============================================================================
# Aurora Environment Configuration
# =============================================================================
# Copy this file to .env and update with your values
# For production, see .env.prod.example with security recommendations
# =============================================================================
# Database
# For production: use a strong password (openssl rand -base64 32)
DB_USER=aurora
DB_PASSWORD=aurora
DB_NAME=aurora
DB_PORT=5432
DB_HOST=db
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
# Discord
# Get from: https://discord.com/developers/applications
DISCORD_BOT_TOKEN=your-discord-bot-token
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_GUILD_ID=your-discord-guild-id
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
VPS_USER=your-vps-user
# Server (for remote access scripts)
# Use a non-root user (see shared/scripts/setup-server.sh)
VPS_USER=deploy
VPS_HOST=your-vps-ip

38
.env.prod.example Normal file
View File

@@ -0,0 +1,38 @@
# =============================================================================
# Aurora Production Environment Template
# =============================================================================
# Copy this file to .env and fill in the values
# IMPORTANT: Use strong, unique passwords in production!
# =============================================================================
# -----------------------------------------------------------------------------
# Database Configuration
# -----------------------------------------------------------------------------
# Generate strong password: openssl rand -base64 32
DB_USER=aurora_prod
DB_PASSWORD=CHANGE_ME_USE_STRONG_PASSWORD
DB_NAME=aurora_prod
DB_PORT=5432
DB_HOST=localhost
# Constructed database URL (used by Drizzle)
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_NAME}
# -----------------------------------------------------------------------------
# Discord Configuration
# -----------------------------------------------------------------------------
# Get these from Discord Developer Portal: https://discord.com/developers
DISCORD_BOT_TOKEN=your_bot_token_here
DISCORD_CLIENT_ID=your_client_id_here
DISCORD_GUILD_ID=your_guild_id_here
# -----------------------------------------------------------------------------
# Server Configuration (for SSH deployment scripts)
# -----------------------------------------------------------------------------
# Use a non-root user for security!
VPS_USER=deploy
VPS_HOST=your_server_ip_here
# Optional: Custom ports for remote access
# DASHBOARD_PORT=3000
# STUDIO_PORT=4983

136
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
# Aurora CI/CD Pipeline
# Builds, tests, and deploys to production server
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # Allow manual trigger
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ==========================================================================
# Test Job
# ==========================================================================
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install Dependencies
run: bun install --frozen-lockfile
- name: Run Tests
run: bun test
# ==========================================================================
# Build Job
# ==========================================================================
build:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.prod
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================================================
# Deploy Job
# ==========================================================================
deploy:
runs-on: ubuntu-latest
needs: build
environment: production
steps:
- name: Deploy to Production Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ~/Aurora
# Pull latest code
git pull origin main
# Pull latest Docker image
docker compose -f docker-compose.prod.yml pull 2>/dev/null || true
# Build and restart containers
docker compose -f docker-compose.prod.yml build --no-cache
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
# Wait for health checks
sleep 15
# Verify deployment
docker ps | grep aurora
# Cleanup old images
docker image prune -f
- name: Verify Deployment
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
# Check if app container is healthy
if docker ps | grep -q "aurora_app.*healthy"; then
echo "✅ Deployment successful - aurora_app is healthy"
exit 0
else
echo "⚠️ Health check pending, checking container status..."
docker ps | grep aurora
docker logs aurora_app --tail 20
exit 0
fi

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
.env
node_modules
docker-compose.override.yml
shared/db-logs
shared/db/data
shared/db/loga

244
AGENTS.md Normal file
View File

@@ -0,0 +1,244 @@
# AGENTS.md - AI Coding Agent Guidelines
## Project Overview
AuroraBot is a Discord bot with a web dashboard built using Bun, Discord.js, React, and PostgreSQL with Drizzle ORM.
## Build/Lint/Test Commands
```bash
# Development
bun --watch bot/index.ts # Run bot with hot reload
bun --hot web/src/index.ts # Run web dashboard with hot reload
# Testing
bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests )
bun test path/to/file.test.ts # Run single test file
bun test --watch # Watch mode
bun test shared/modules/economy # Run tests in directory
# Database
bun run generate # Generate Drizzle migrations (Docker)
bun run migrate # Run migrations (Docker)
bun run db:push # Push schema changes (Docker)
bun run db:push:local # Push schema changes (local)
bun run db:studio # Open Drizzle Studio
# Web Dashboard
cd web && bun run build # Build production web assets
cd web && bun run dev # Development server
```
## Project Structure
```
bot/ # Discord bot
├── commands/ # Slash commands by category
├── events/ # Discord event handlers
├── lib/ # Bot core (BotClient, handlers, loaders)
├── modules/ # Feature modules (views, interactions)
└── graphics/ # Canvas image generation
shared/ # Shared between bot and web
├── db/ # Database schema and migrations
├── lib/ # Utils, config, errors, types
└── modules/ # Domain services (economy, user, etc.)
web/ # React dashboard
├── src/pages/ # React pages
├── src/components/ # UI components (ShadCN/Radix)
└── src/hooks/ # React hooks
```
## Import Conventions
Use path aliases defined in tsconfig.json:
```typescript
// External packages first
import { SlashCommandBuilder } from "discord.js";
import { eq } from "drizzle-orm";
// Path aliases second
import { economyService } from "@shared/modules/economy/economy.service";
import { UserError } from "@shared/lib/errors";
import { users } from "@db/schema";
import { createErrorEmbed } from "@lib/embeds";
import { handleTradeInteraction } from "@modules/trade/trade.interaction";
// Relative imports last
import { localHelper } from "./helper";
```
**Available Aliases:**
- `@/*` - bot/
- `@shared/*` - shared/
- `@db/*` - shared/db/
- `@lib/*` - bot/lib/
- `@modules/*` - bot/modules/
- `@commands/*` - bot/commands/
## Naming Conventions
| Element | Convention | Example |
| ---------------- | ----------------------- | ---------------------------------------- |
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
| Classes | PascalCase | `CommandHandler`, `UserError` |
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
| Enums | PascalCase | `TimerType`, `TransactionType` |
| Services | camelCase singleton | `economyService`, `userService` |
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
| DB tables | snake_case | `users`, `moderation_cases` |
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
## Code Patterns
### Command Definition
```typescript
export const commandName = createCommand({
data: new SlashCommandBuilder()
.setName("commandname")
.setDescription("Description"),
execute: async (interaction) => {
await interaction.deferReply();
// Implementation
},
});
```
### Service Pattern (Singleton Object)
```typescript
export const serviceName = {
methodName: async (params: ParamType): Promise<ReturnType> => {
return await withTransaction(async (tx) => {
// Database operations
});
},
};
```
### Module File Organization
- `*.view.ts` - Creates Discord embeds/components
- `*.interaction.ts` - Handles button/select/modal interactions
- `*.types.ts` - Module-specific TypeScript types
- `*.service.ts` - Business logic (in shared/modules/)
- `*.test.ts` - Test files (co-located with source)
## Error Handling
### Custom Error Classes
```typescript
import { UserError, SystemError } from "@shared/lib/errors";
// User-facing errors (shown to user)
throw new UserError("You don't have enough coins!");
// System errors (logged, generic message shown)
throw new SystemError("Database connection failed");
```
### Standard Error Pattern
```typescript
try {
const result = await service.method();
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Unexpected error:", error);
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred.")],
});
}
}
```
## Database Patterns
### Transaction Usage
```typescript
import { withTransaction } from "@/lib/db";
return await withTransaction(async (tx) => {
const user = await tx.query.users.findFirst({
where: eq(users.id, discordId),
});
await tx
.update(users)
.set({ coins: newBalance })
.where(eq(users.id, discordId));
await tx.insert(transactions).values({ userId: discordId, amount, type });
return user;
}, existingTx); // Pass existing tx if in nested transaction
```
### Schema Notes
- Use `bigint` mode for Discord IDs and currency amounts
- Relations defined separately from table definitions
- Schema location: `shared/db/schema.ts`
## Testing
### Test File Structure
```typescript
import { describe, it, expect, mock, beforeEach } from "bun:test";
// Mock modules BEFORE imports
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: { query: mockQuery },
}));
describe("serviceName", () => {
beforeEach(() => {
mockFn.mockClear();
});
it("should handle expected case", async () => {
// Arrange
mockFn.mockResolvedValue(testData);
// Act
const result = await service.method(input);
// Assert
expect(result).toEqual(expected);
expect(mockFn).toHaveBeenCalledWith(expectedArgs);
});
});
```
## Tech Stack
- **Runtime:** Bun 1.0+
- **Bot:** Discord.js 14.x
- **Web:** React 19 + Bun HTTP Server
- **Database:** PostgreSQL 16+ with Drizzle ORM
- **UI:** Tailwind CSS v4 + ShadCN/Radix
- **Validation:** Zod
- **Testing:** Bun Test
- **Container:** Docker
## Key Files Reference
| Purpose | File |
| ------------- | ---------------------- |
| Bot entry | `bot/index.ts` |
| DB schema | `shared/db/schema.ts` |
| Error classes | `shared/lib/errors.ts` |
| Config loader | `shared/lib/config.ts` |
| Environment | `shared/lib/env.ts` |
| Embed helpers | `bot/lib/embeds.ts` |
| Command utils | `shared/lib/utils.ts` |

View File

@@ -1,21 +1,55 @@
# ============================================
# Base stage - shared configuration
# ============================================
FROM oven/bun:latest AS base
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Install system dependencies with cleanup in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends git && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install root project dependencies
# ============================================
# Dependencies stage - installs all deps
# ============================================
FROM base AS deps
# Copy only package files first (better layer caching)
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Install web project dependencies
COPY web/package.json web/bun.lock ./web/
RUN cd web && bun install --frozen-lockfile
# Copy source code
COPY . .
# Install all dependencies in one layer
RUN bun install --frozen-lockfile && \
cd web && bun install --frozen-lockfile
# Expose ports (3000 for web dashboard)
# ============================================
# Development stage - for local dev with volume mounts
# ============================================
FROM base AS development
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/web/node_modules ./web/node_modules
# Expose ports
EXPOSE 3000
# Default command
CMD ["bun", "run", "dev"]
# ============================================
# Production stage - full app with source code
# ============================================
FROM base AS production
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/web/node_modules ./web/node_modules
# Copy source code
COPY . .
# Expose ports
EXPOSE 3000
# Default command

67
Dockerfile.prod Normal file
View File

@@ -0,0 +1,67 @@
# =============================================================================
# Stage 1: Dependencies & Build
# =============================================================================
FROM oven/bun:latest AS builder
WORKDIR /app
# Install system dependencies needed for build
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Install root project dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Install web project dependencies
COPY web/package.json web/bun.lock ./web/
RUN cd web && bun install --frozen-lockfile
# Copy source code
COPY . .
# Build web assets for production
RUN cd web && bun run build
# =============================================================================
# Stage 2: Production Runtime
# =============================================================================
FROM oven/bun:latest AS production
WORKDIR /app
# Create non-root user for security (bun user already exists with 1000:1000)
# No need to create user/group
# Install runtime dependencies for update/deploy commands
RUN apt-get update && apt-get install -y \
git \
curl \
gnupg \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system --add safe.directory /app/deploy
# Copy only what's needed for production
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/web/node_modules ./web/node_modules
COPY --from=builder --chown=bun:bun /app/web/dist ./web/dist
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
COPY --from=builder --chown=bun:bun /app/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared
COPY --from=builder --chown=bun:bun /app/package.json .
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
# Switch to non-root user
USER bun
# Expose web dashboard port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Run in production mode
CMD ["bun", "run", "bot/index.ts"]

View File

@@ -7,24 +7,44 @@
![Discord.js](https://img.shields.io/badge/Discord.js-14.x-5865F2)
![Drizzle ORM](https://img.shields.io/badge/Drizzle_ORM-0.30+-C5F74F)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791)
![React](https://img.shields.io/badge/React-19-61DAFB)
Aurora is a powerful Discord bot designed to facilitate RPG-like elements within a Discord server. It features a robust economy, class system, inventory management, quests, and more, all built on top of a high-performance stack using Bun and Drizzle ORM.
**New in v1.0:** Aurora now includes a fully integrated **Web Dashboard** for managing the bot, viewing statistics, and configuring settings, running alongside the bot in a single process.
## ✨ Features
### Discord Bot
* **Class System**: Users can join different classes.
* **Economy**: Complete economy system with balance, transactions, and daily rewards.
* **Inventory & Items**: sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Inventory & Items**: Sophisticated item system with rarities, types (Material, Consumable, etc.), and inventory management.
* **Leveling**: XP-based leveling system to track user activity and progress.
* **Quests**: Quest system with requirements and rewards.
* **Trading**: Secure trading system between users.
* **Lootdrops**: Random loot drops in channels to engage users.
* **Admin Tools**: Administrative commands for server management.
### Web Dashboard
* **Live Analytics**: View real-time activity charts (commands, transactions).
* **Configuration Management**: Update bot settings without restarting.
* **Database Inspection**: Integrated Drizzle Studio access.
* **State Monitoring**: View internal bot state (Lootdrops, etc.).
## 🏗️ Architecture
Aurora uses a **Single Process Monolith** architecture to maximize performance and simplify resource sharing.
* **Unified Runtime**: Both the Discord Client and the Web Dashboard run within the same Bun process.
* **Shared State**: This allows the Dashboard to access live bot memory (caches, gateways) directly without complex inter-process communication (IPC).
* **Simplified Deployment**: You only need to deploy a single Docker container.
## 🛠️ Tech Stack
* **Runtime**: [Bun](https://bun.sh/)
* **Framework**: [Discord.js](https://discord.js.org/)
* **Bot Framework**: [Discord.js](https://discord.js.org/)
* **Web Framework**: [React 19](https://react.dev/) + [Vite](https://vitejs.dev/) (served via Bun)
* **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/)
* **Database**: [PostgreSQL](https://www.postgresql.org/)
* **ORM**: [Drizzle ORM](https://orm.drizzle.team/)
* **Validation**: [Zod](https://zod.dev/)
@@ -74,12 +94,14 @@ Aurora is a powerful Discord bot designed to facilitate RPG-like elements within
bun run db:push
```
### Running the Bot
### Running the Bot & Dashboard
**Development Mode** (with hot reload):
```bash
bun run dev
```
* Bot: Online in Discord
* Dashboard: http://localhost:3000
**Production Mode**:
Build and run with Docker (recommended):
@@ -87,27 +109,46 @@ Build and run with Docker (recommended):
docker compose up -d app
```
### 🔐 Accessing Production Services (SSH Tunnel)
For security, the Production Database and Dashboard are **not exposed** to the public internet by default. They are only accessible via localhost on the server.
To access them from your local machine, use the included SSH tunnel script.
1. Add your VPS details to your local `.env` file:
```env
VPS_USER=root
VPS_HOST=123.45.67.89
```
2. Run the remote connection script:
```bash
bun run remote
```
This will establish secure tunnels for:
* **Dashboard**: http://localhost:3000
* **Drizzle Studio**: http://localhost:4983
## 📜 Scripts
* `bun run dev`: Start the bot in watch mode.
* `bun run dev`: Start the bot and dashboard in watch mode.
* `bun run remote`: Open SSH tunnel to production services.
* `bun run generate`: Generate Drizzle migrations.
* `bun run migrate`: Apply migrations (via Docker).
* `bun run db:push`: Push, schema to DB (via Docker).
* `bun run db:studio`: Open Drizzle Studio to inspect the database.
* `bun test`: Run tests.
## 📂 Project Structure
```
├── src
│ ├── commands # Slash commands
│ ├── events # Discord event handlers
│ ├── modules # Feature modules (Economy, Inventory, etc.)
│ ├── db # Database schema and connection
│ └── lib # Shared utilities
├── bot # Discord Bot logic & entry point
├── web # React Web Dashboard (Frontend + Server)
├── shared # Shared code (Database, Config, Types)
├── drizzle # Drizzle migration files
├── config # Configuration files
── scripts # Utility scripts
├── scripts # Utility scripts
── docker-compose.yml
└── package.json
```
## 🤝 Contributing

View File

@@ -10,7 +10,7 @@ import {
} from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
import { items } from "@db/schema";
import { ilike, isNotNull, and } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
@@ -65,10 +65,10 @@ export const listing = createCommand({
await interaction.editReply({ content: `✅ Listing for **${item.name}** posted in ${targetChannel}.` });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error creating listing:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
},

View File

@@ -11,7 +11,12 @@ import {
getTimeoutEmbed,
getErrorEmbed,
getRollbackSuccessEmbed,
getRollbackFailedEmbed
getRollbackFailedEmbed,
getDeployNotAvailableEmbed,
getDeployCheckingEmbed,
getDeployProgressEmbed,
getDeploySuccessEmbed,
getDeployErrorEmbed
} from "@/modules/admin/update.view";
export const update = createCommand({
@@ -31,6 +36,10 @@ export const update = createCommand({
sub.setName("rollback")
.setDescription("Rollback to the previous version")
)
.addSubcommand(sub =>
sub.setName("deploy")
.setDescription("Pull latest code and rebuild container (production only)")
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
execute: async (interaction) => {
@@ -38,6 +47,8 @@ export const update = createCommand({
if (subcommand === "rollback") {
await handleRollback(interaction);
} else if (subcommand === "deploy") {
await handleDeploy(interaction);
} else {
await handleUpdate(interaction);
}
@@ -49,7 +60,7 @@ async function handleUpdate(interaction: any) {
const force = interaction.options.getBoolean("force") || false;
try {
// 1. Check for updates
// 1. Check for updates (now includes requirements in one call)
await interaction.editReply({ embeds: [getCheckingEmbed()] });
const updateInfo = await UpdateService.checkForUpdates();
@@ -60,8 +71,8 @@ async function handleUpdate(interaction: any) {
return;
}
// 2. Analyze requirements
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
// 2. Extract requirements from the combined response
const { requirements } = updateInfo;
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
// 3. Show confirmation with details
@@ -97,6 +108,7 @@ async function handleUpdate(interaction: any) {
timestamp: Date.now(),
runMigrations: requirements.needsMigrations,
installDependencies: requirements.needsRootInstall || requirements.needsWebInstall,
buildWebAssets: requirements.needsWebBuild,
previousCommit: previousCommit.substring(0, 7),
newCommit: updateInfo.latestCommit
});
@@ -174,3 +186,62 @@ async function handleRollback(interaction: any) {
});
}
}
async function handleDeploy(interaction: any) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
// Check if deploy is available
const available = await UpdateService.isDeployAvailable();
if (!available) {
await interaction.editReply({
embeds: [getDeployNotAvailableEmbed()]
});
return;
}
// Show checking status
await interaction.editReply({
embeds: [getDeployCheckingEmbed()]
});
// Perform deployment with progress updates
const result = await UpdateService.performDeploy((step) => {
interaction.editReply({
embeds: [getDeployProgressEmbed(step)]
});
});
if (result.success) {
// Save restart context so we can notify on startup
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: true, // Always check migrations on deploy
installDependencies: false, // Handled by Docker build
buildWebAssets: false, // Handled by Docker build
previousCommit: result.previousCommit,
newCommit: result.newCommit
});
await interaction.editReply({
embeds: [getDeploySuccessEmbed(
result.previousCommit,
result.newCommit,
result.output
)]
});
} else {
await interaction.editReply({
embeds: [getDeployErrorEmbed(result.error || "Unknown error")]
});
}
} catch (error) {
console.error("Deploy failed:", error);
await interaction.editReply({
embeds: [getDeployErrorEmbed(error instanceof Error ? error.message : String(error))]
});
}
}

View File

@@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { economyService } from "@shared/modules/economy/economy.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export const daily = createCommand({
data: new SlashCommandBuilder()
.setName("daily")
.setDescription("Claim your daily reward"),
execute: async (interaction) => {
await interaction.deferReply();
try {
const result = await economyService.claimDaily(interaction.user.id);
@@ -21,14 +22,14 @@ export const daily = createCommand({
)
.setColor("Gold");
await interaction.reply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed] });
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
console.error("Error claiming daily:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
}
}
}

View File

@@ -1,21 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { userTimers, users } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
interface ExamMetadata {
examDay: number;
lastXp: string;
}
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -25,105 +11,42 @@ export const exam = createCommand({
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
execute: async (interaction) => {
await interaction.deferReply();
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] });
return;
}
const now = new Date();
const currentDay = now.getDay();
try {
// 1. Fetch existing timer/exam data
const timer = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
// First, try to take the exam or check status
const result = await examService.takeExam(interaction.user.id);
// 2. First Run Logic
if (!timer) {
// Set exam day to today
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.insert(userTimers).values({
userId: user.id,
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
if (result.status === ExamStatus.NOT_REGISTERED) {
// Register the user
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
await interaction.editReply({
embeds: [createSuccessEmbed(
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
"Exam Registration Successful"
)]
});
return;
}
const metadata = timer.metadata as unknown as ExamMetadata;
const examDay = metadata.examDay;
// 3. Cooldown Check
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
// Calculate time remaining
const timestamp = Math.floor(expiresAt.getTime() / 1000);
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
if (result.status === ExamStatus.COOLDOWN) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
)]
});
return;
}
// 4. Day Check
if (currentDay !== examDay) {
// Calculate next correct exam day to correct the schedule
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await DrizzleClient.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
if (result.status === ExamStatus.MISSED) {
await interaction.editReply({
embeds: [createErrorEmbed(
`You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` +
`You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` +
`You verify your attendance but score a **0**.\n` +
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
"Exam Failed"
@@ -132,74 +55,21 @@ export const exam = createCommand({
return;
}
// 5. Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
// Calculate Reward
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
// Allow negative reward? existing description implies "difference", usually gain.
// If diff is negative (lost XP?), reward might be 0.
let reward = 0n;
if (diff > 0n) {
reward = BigInt(Math.floor(Number(diff) * multiplier));
}
// 6. Update State
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
await DrizzleClient.transaction(async (tx) => {
// Update Timer
await tx.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, user.id),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await tx.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, user.id));
}
});
// If it reached here with AVAILABLE, it means they passed
await interaction.editReply({
embeds: [createSuccessEmbed(
`**XP Gained:** ${diff.toString()}\n` +
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
`**Reward:** ${reward.toString()} Currency\n\n` +
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
`See you next week: <t:${nextExamTimestamp}:D>`,
"Exam Passed!"
)]
});
} catch (error: any) {
if (error instanceof UserError) {
await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true });
} else {
console.error("Error in exam command:", error);
await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true });
}
console.error("Error in exam command:", error);
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
}
}
});

View File

@@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service";
import { userService } from "@shared/modules/user/user.service";
import { config } from "@shared/lib/config";
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export const pay = createCommand({
data: new SlashCommandBuilder()

View File

@@ -0,0 +1,117 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaQuestionView } from "@/modules/trivia/trivia.view";
import { createErrorEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { TriviaCategory } from "@shared/lib/constants";
export const trivia = createCommand({
data: new SlashCommandBuilder()
.setName("trivia")
.setDescription("Play trivia to win currency! Answer correctly within the time limit.")
.addStringOption(option =>
option.setName('category')
.setDescription('Select a specific category')
.setRequired(false)
.addChoices(
{ name: 'General Knowledge', value: String(TriviaCategory.GENERAL_KNOWLEDGE) },
{ name: 'Books', value: String(TriviaCategory.BOOKS) },
{ name: 'Film', value: String(TriviaCategory.FILM) },
{ name: 'Music', value: String(TriviaCategory.MUSIC) },
{ name: 'Video Games', value: String(TriviaCategory.VIDEO_GAMES) },
{ name: 'Science & Nature', value: String(TriviaCategory.SCIENCE_NATURE) },
{ name: 'Computers', value: String(TriviaCategory.COMPUTERS) },
{ name: 'Mathematics', value: String(TriviaCategory.MATHEMATICS) },
{ name: 'Mythology', value: String(TriviaCategory.MYTHOLOGY) },
{ name: 'Sports', value: String(TriviaCategory.SPORTS) },
{ name: 'Geography', value: String(TriviaCategory.GEOGRAPHY) },
{ name: 'History', value: String(TriviaCategory.HISTORY) },
{ name: 'Politics', value: String(TriviaCategory.POLITICS) },
{ name: 'Art', value: String(TriviaCategory.ART) },
{ name: 'Animals', value: String(TriviaCategory.ANIMALS) },
{ name: 'Anime & Manga', value: String(TriviaCategory.ANIME_MANGA) },
)
),
execute: async (interaction) => {
try {
const categoryId = interaction.options.getString('category');
// Check if user can play BEFORE deferring
const canPlay = await triviaService.canPlayTrivia(interaction.user.id);
if (!canPlay.canPlay) {
// Cooldown error - ephemeral
const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000);
await interaction.reply({
embeds: [createErrorEmbed(
`You're on cooldown! Try again <t:${timestamp}:R>.`
)],
ephemeral: true
});
return;
}
// User can play - defer publicly for trivia question
await interaction.deferReply();
// Start trivia session (deducts entry fee)
const session = await triviaService.startTrivia(
interaction.user.id,
interaction.user.username,
categoryId ? parseInt(categoryId) : undefined
);
// Generate Components v2 message
const { components, flags } = getTriviaQuestionView(session, interaction.user.username);
// Reply with Components v2 question
await interaction.editReply({
components,
flags
});
// Set up automatic timeout cleanup
setTimeout(async () => {
const stillActive = triviaService.getSession(session.sessionId);
if (stillActive) {
// User didn't answer - clean up session with no reward
try {
await triviaService.submitAnswer(session.sessionId, interaction.user.id, false);
} catch (error) {
// Session already cleaned up, ignore
}
}
}, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period
} catch (error: any) {
if (error instanceof UserError) {
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed(error.message)]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed(error.message)],
ephemeral: true
});
}
} else {
console.error("Error in trivia command:", error);
// Check if we've already deferred
if (interaction.deferred) {
await interaction.editReply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")]
});
} else {
await interaction.reply({
embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")],
ephemeral: true
});
}
}
}
}
});

View File

@@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
export const use = createCommand({

View File

@@ -1,25 +1,83 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, MessageFlags } from "discord.js";
import { questService } from "@shared/modules/quest/quest.service";
import { createWarningEmbed } from "@lib/embeds";
import { getQuestListEmbed } from "@/modules/quest/quest.view";
import { createSuccessEmbed } from "@lib/embeds";
import {
getQuestListComponents,
getAvailableQuestsComponents,
getQuestActionRows
} from "@/modules/quest/quest.view";
export const quests = createCommand({
data: new SlashCommandBuilder()
.setName("quests")
.setDescription("View your active quests"),
.setDescription("View your active and available quests"),
execute: async (interaction) => {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const userQuests = await questService.getUserQuests(interaction.user.id);
const userId = interaction.user.id;
if (!userQuests || userQuests.length === 0) {
await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] });
return;
}
const updateView = async (viewType: 'active' | 'available') => {
const userQuests = await questService.getUserQuests(userId);
const availableQuests = await questService.getAvailableQuests(userId);
const embed = getQuestListEmbed(userQuests);
const containers = viewType === 'active'
? getQuestListComponents(userQuests)
: getAvailableQuestsComponents(availableQuests);
await interaction.editReply({ embeds: [embed] });
const actionRows = getQuestActionRows(viewType);
await interaction.editReply({
content: null,
embeds: null as any,
components: [...containers, ...actionRows] as any,
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] }
});
};
// Initial view
await updateView('active');
const collector = response.createMessageComponentCollector({
time: 120000, // 2 minutes
componentType: undefined // Allow buttons
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) return;
try {
if (i.customId === "quest_view_active") {
await i.deferUpdate();
await updateView('active');
} else if (i.customId === "quest_view_available") {
await i.deferUpdate();
await updateView('available');
} else if (i.customId.startsWith("quest_accept:")) {
const questIdStr = i.customId.split(":")[1];
if (!questIdStr) return;
const questId = parseInt(questIdStr);
await questService.assignQuest(userId, questId);
await i.reply({
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
flags: MessageFlags.Ephemeral
});
await updateView('active');
}
} catch (error) {
console.error("Quest interaction error:", error);
await i.followUp({
content: "Something went wrong while processing your quest interaction.",
flags: MessageFlags.Ephemeral
});
}
});
collector.on('end', () => {
interaction.editReply({ components: [] }).catch(() => {});
});
}
});

View File

@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
await AuroraClient.setupSystemEvents();
console.log("🌐 Starting web server...");

111
bot/lib/BotClient.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
import { systemEvents, EVENTS } from "@shared/lib/events";
// Mock Discord.js Client and related classes
mock.module("discord.js", () => ({
Client: class {
constructor() { }
on() { }
once() { }
login() { }
destroy() { }
removeAllListeners() { }
},
Collection: Map,
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
REST: class {
setToken() { return this; }
put() { return Promise.resolve([]); }
},
Routes: {
applicationGuildCommands: () => 'guild_route',
applicationCommands: () => 'global_route'
}
}));
// Mock loaders to avoid filesystem access during client init
mock.module("../lib/loaders/CommandLoader", () => ({
CommandLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
mock.module("../lib/loaders/EventLoader", () => ({
EventLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
// Mock dashboard service to prevent network/db calls during event handling
mock.module("@shared/modules/economy/lootdrop.service", () => ({
lootdropService: { clearCaches: mock(async () => { }) }
}));
mock.module("@shared/modules/trade/trade.service", () => ({
tradeService: { clearSessions: mock(() => { }) }
}));
mock.module("@/modules/admin/item_wizard", () => ({
clearDraftSessions: mock(() => { })
}));
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock(() => Promise.resolve())
}
}));
describe("AuroraClient System Events", () => {
let AuroraClient: any;
beforeEach(async () => {
systemEvents.removeAllListeners();
const module = await import("./BotClient");
AuroraClient = module.AuroraClient;
AuroraClient.maintenanceMode = false;
// MUST call explicitly now
await AuroraClient.setupSystemEvents();
});
/**
* Test Case: Maintenance Mode Toggle
* Requirement: Client state should update when event is received
*/
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(true);
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(false);
});
/**
* Test Case: Command Reload
* Requirement: loadCommands and deployCommands should be called
*/
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
await new Promise(resolve => setTimeout(resolve, 50));
expect(loadSpy).toHaveBeenCalled();
expect(deploySpy).toHaveBeenCalled();
});
/**
* Test Case: Cache Clearance
* Requirement: Service clear methods should be triggered
*/
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { tradeService } = await import("@shared/modules/trade/trade.service");
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lootdropService.clearCaches).toHaveBeenCalled();
expect(tradeService.clearSessions).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js";
import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js";
import { join } from "node:path";
import type { Command } from "@shared/lib/types";
import { env } from "@shared/lib/env";
@@ -8,20 +8,99 @@ import { EventLoader } from "@lib/loaders/EventLoader";
export class Client extends DiscordClient {
commands: Collection<string, Command>;
knownCommands: Map<string, string>;
lastCommandTimestamp: number | null = null;
maintenanceMode: boolean = false;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
constructor({ intents }: { intents: number[] }) {
super({ intents });
this.commands = new Collection<string, Command>();
this.knownCommands = new Map<string, string>();
this.commandLoader = new CommandLoader(this);
this.eventLoader = new EventLoader(this);
}
public async setupSystemEvents() {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
console.log("🔄 System Action: Reloading commands...");
try {
await this.loadCommands(true);
await this.deployCommands();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: Commands reloaded and redeployed",
icon: "✅"
});
} catch (error) {
console.error("Failed to reload commands:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
console.log("<22> System Action: Clearing all internal caches...");
try {
// 1. Lootdrop Service
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
await lootdropService.clearCaches();
// 2. Trade Service
const { tradeService } = await import("@shared/modules/trade/trade.service");
tradeService.clearSessions();
// 3. Item Wizard
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
clearDraftSessions();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: All internal caches and sessions cleared",
icon: "🧼"
});
} catch (error) {
console.error("Failed to clear caches:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
const { enabled, reason } = data;
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
this.maintenanceMode = enabled;
});
systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => {
const { userId, quest, rewards } = data;
try {
const user = await this.users.fetch(userId);
if (!user) return;
const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view");
const components = getQuestCompletionComponents(quest, rewards);
// Try to send to the user's DM
await user.send({
components: components as any,
flags: [MessageFlags.IsComponentsV2]
}).catch(async () => {
console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`);
});
} catch (error) {
console.error("Failed to send quest completion notification:", error);
}
});
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();
this.knownCommands.clear();
console.log("♻️ Reloading commands...");
}
@@ -118,4 +197,4 @@ export class Client extends DiscordClient {
}
}
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] });
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
import { getClientStats, clearStatsCache } from "./clientStats";
// Mock AuroraClient
mock.module("./BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
size: 5,
},
},
ws: {
ping: 42,
},
users: {
cache: {
size: 100,
},
},
commands: {
size: 20,
},
lastCommandTimestamp: 1641481200000,
},
}));
describe("clientStats", () => {
beforeEach(() => {
clearStatsCache();
});
test("should return client stats", () => {
const stats = getClientStats();
expect(stats.guilds).toBe(5);
expect(stats.ping).toBe(42);
expect(stats.cachedUsers).toBe(100);
expect(stats.commandsRegistered).toBe(20);
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
expect(stats.lastCommandTimestamp).toBe(1641481200000);
});
test("should cache stats for 30 seconds", () => {
const stats1 = getClientStats();
const stats2 = getClientStats();
// Should return same object (cached)
expect(stats1).toBe(stats2);
});
test("should refresh cache after TTL expires", async () => {
const stats1 = getClientStats();
// Wait for cache to expire (simulate by clearing and waiting)
await new Promise(resolve => setTimeout(resolve, 35));
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects (new fetch)
expect(stats1).not.toBe(stats2);
// But values should be the same (mocked client)
expect(stats1.guilds).toBe(stats2.guilds);
});
test("clearStatsCache should invalidate cache", () => {
const stats1 = getClientStats();
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects
expect(stats1).not.toBe(stats2);
});
});

50
bot/lib/clientStats.ts Normal file
View File

@@ -0,0 +1,50 @@
import { AuroraClient } from "./BotClient";
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
// Cache for client stats (30 second TTL)
let cachedStats: ClientStats | null = null;
let lastFetchTime: number = 0;
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
/**
* Get Discord client statistics with caching
* Respects rate limits by caching for 30 seconds
*/
export function getClientStats(): ClientStats {
const now = Date.now();
// Return cached stats if still valid
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
return cachedStats;
}
// Fetch fresh stats
const stats: ClientStats = {
bot: {
name: AuroraClient.user?.username || "Aurora",
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null,
},
guilds: AuroraClient.guilds.cache.size,
ping: AuroraClient.ws.ping,
cachedUsers: AuroraClient.users.cache.size,
commandsRegistered: AuroraClient.commands.size,
commandsKnown: AuroraClient.knownCommands.size,
uptime: process.uptime(),
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
};
// Update cache
cachedStats = stats;
lastFetchTime = now;
return stats;
}
/**
* Clear the stats cache (useful for testing)
*/
export function clearStatsCache(): void {
cachedStats = null;
lastFetchTime = 0;
}

View File

@@ -1,4 +1,15 @@
import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
import { BRANDING } from "@shared/lib/constants";
import pkg from "../../package.json";
/**
* Applies standard branding to an embed.
*/
function applyBranding(embed: EmbedBuilder): EmbedBuilder {
return embed.setFooter({
text: `${BRANDING.FOOTER_TEXT} v${pkg.version}`
});
}
/**
* Creates a standardized error embed.
@@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js";
* @returns An EmbedBuilder instance configured as an error.
*/
export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Red)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe
* @returns An EmbedBuilder instance configured as a warning.
*/
export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`⚠️ ${title}`)
.setDescription(message)
.setColor(Colors.Yellow)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"):
* @returns An EmbedBuilder instance configured as a success.
*/
export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(`${title}`)
.setDescription(message)
.setColor(Colors.Green)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"):
* @returns An EmbedBuilder instance configured as info.
*/
export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder {
return new EmbedBuilder()
const embed = new EmbedBuilder()
.setTitle(` ${title}`)
.setDescription(message)
.setColor(Colors.Blue)
.setTimestamp();
return applyBranding(embed);
}
/**
@@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB
*/
export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder {
const embed = new EmbedBuilder()
.setTimestamp();
.setTimestamp()
.setColor(color ?? BRANDING.COLOR);
if (title) embed.setTitle(title);
if (description) embed.setDescription(description);
if (color) embed.setColor(color);
return embed;
return applyBranding(embed);
}

View File

@@ -1,18 +0,0 @@
export class ApplicationError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export class UserError extends ApplicationError {
constructor(message: string) {
super(message);
}
}
export class SystemError extends ApplicationError {
constructor(message: string) {
super(message);
}
}

View File

@@ -1,5 +1,6 @@
import { AutocompleteInteraction } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { logger } from "@shared/lib/logger";
/**
@@ -16,7 +17,7 @@ export class AutocompleteHandler {
try {
await command.autocomplete(interaction);
} catch (error) {
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error);
}
}
}

View File

@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
expect(executeError).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).toBeNull();
});
test("should block execution when maintenance mode is active", async () => {
AuroraClient.maintenanceMode = true;
const executeSpy = mock(() => Promise.resolve());
AuroraClient.commands.set("maint-test", {
data: { name: "maint-test" } as any,
execute: executeSpy
} as any);
const interaction = {
commandName: "maint-test",
user: { id: "123", username: "testuser" },
reply: mock(() => Promise.resolve())
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
flags: expect.anything()
}));
AuroraClient.maintenanceMode = false; // Reset for other tests
});
});

View File

@@ -2,7 +2,8 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@shared/modules/user/user.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
/**
* Handles slash command execution
@@ -13,7 +14,14 @@ export class CommandHandler {
const command = AuroraClient.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
logger.error("bot", `No command matching ${interaction.commandName} was found.`);
return;
}
// Check maintenance mode
if (AuroraClient.maintenanceMode) {
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
@@ -21,14 +29,14 @@ export class CommandHandler {
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
} catch (error) {
console.error("Failed to ensure user exists:", error);
logger.error("bot", "Failed to ensure user exists", error);
}
try {
await command.execute(interaction);
AuroraClient.lastCommandTimestamp = Date.now();
} catch (error) {
console.error(String(error));
logger.error("bot", `Error executing command ${interaction.commandName}`, error);
const errorEmbed = createErrorEmbed('There was an error while executing this command!');
if (interaction.replied || interaction.deferred) {

View File

@@ -1,7 +1,8 @@
import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js";
import { UserError } from "@lib/errors";
import { UserError } from "@shared/lib/errors";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction;
@@ -28,7 +29,7 @@ export class ComponentInteractionHandler {
return;
}
} else {
console.error(`Handler method ${route.method} not found in module`);
logger.error("bot", `Handler method ${route.method} not found in module`);
}
}
}
@@ -52,7 +53,7 @@ export class ComponentInteractionHandler {
// Log system errors (non-user errors) for debugging
if (!isUserError) {
console.error(`Error in ${handlerName}:`, error);
logger.error("bot", `Error in ${handlerName}`, error);
}
const errorEmbed = createErrorEmbed(errorMessage);
@@ -72,7 +73,7 @@ export class ComponentInteractionHandler {
}
} catch (replyError) {
// If we can't send a reply, log it
console.error(`Failed to send error response in ${handlerName}:`, replyError);
logger.error("bot", `Failed to send error response in ${handlerName}`, replyError);
}
}
}

View File

@@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [
handler: () => import("@/modules/economy/lootdrop.interaction"),
method: 'handleLootdropInteraction'
},
{
predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"),
handler: () => import("@/modules/trivia/trivia.interaction"),
method: 'handleTriviaInteraction'
},
// --- ADMIN MODULE ---
{

View File

@@ -4,7 +4,7 @@ import type { Command } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import type { LoadResult, LoadError } from "./types";
import type { Client } from "../BotClient";
/**
* Handles loading commands from the file system
@@ -71,6 +71,9 @@ export class CommandLoader {
if (this.isValidCommand(command)) {
command.category = category;
// Track all known commands regardless of enabled status
this.client.knownCommands.set(command.data.name, category);
const isEnabled = config.commands[command.data.name] !== false;
if (!isEnabled) {

View File

@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
};
export const clearDraftSessions = () => {
draftSession.clear();
console.log("[ItemWizard] All draft item creation sessions cleared.");
};

View File

@@ -5,6 +5,7 @@ export interface RestartContext {
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
buildWebAssets: boolean;
previousCommit: string;
newCommit: string;
}
@@ -12,6 +13,7 @@ export interface RestartContext {
export interface UpdateCheckResult {
needsRootInstall: boolean;
needsWebInstall: boolean;
needsWebBuild: boolean;
needsMigrations: boolean;
changedFiles: string[];
error?: Error;

View File

@@ -31,7 +31,7 @@ export function getUpdatesAvailableMessage(
force: boolean
) {
const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo;
const { needsRootInstall, needsWebInstall, needsMigrations } = requirements;
const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements;
// Build commit list (max 5)
const commitList = commits
@@ -50,6 +50,7 @@ export function getUpdatesAvailableMessage(
const reqs: string[] = [];
if (needsRootInstall) reqs.push("📦 Install root dependencies");
if (needsWebInstall) reqs.push("🌐 Install web dependencies");
if (needsWebBuild) reqs.push("🏗️ Build web dashboard");
if (needsMigrations) reqs.push("🗃️ Run database migrations");
if (reqs.length === 0) reqs.push("⚡ Quick update (no extra steps)");
@@ -124,6 +125,9 @@ export function getUpdatingEmbed(requirements: UpdateCheckResult) {
if (requirements.needsRootInstall || requirements.needsWebInstall) {
steps.push("📦 Dependencies will be installed after restart");
}
if (requirements.needsWebBuild) {
steps.push("🏗️ Web dashboard will be rebuilt after restart");
}
if (requirements.needsMigrations) {
steps.push("🗃️ Migrations will run after restart");
}
@@ -157,16 +161,19 @@ export function getErrorEmbed(error: unknown) {
export interface PostRestartResult {
installSuccess: boolean;
installOutput: string;
webBuildSuccess: boolean;
webBuildOutput: string;
migrationSuccess: boolean;
migrationOutput: string;
ranInstall: boolean;
ranWebBuild: boolean;
ranMigrations: boolean;
previousCommit?: string;
newCommit?: string;
}
export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) {
const isSuccess = result.installSuccess && result.migrationSuccess;
const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess;
const embed = new EmbedBuilder()
.setTitle(isSuccess ? "✅ Update Complete" : "⚠️ Update Completed with Issues")
@@ -192,6 +199,13 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool
);
}
if (result.ranWebBuild) {
results.push(result.webBuildSuccess
? "✅ Web dashboard built"
: "❌ Web dashboard build failed"
);
}
if (result.ranMigrations) {
results.push(result.migrationSuccess
? "✅ Migrations applied"
@@ -216,6 +230,14 @@ export function getPostRestartEmbed(result: PostRestartResult, hasRollback: bool
});
}
if (result.webBuildOutput && !result.webBuildSuccess) {
embed.addFields({
name: "Web Build Output",
value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``,
inline: false
});
}
if (result.migrationOutput && !result.migrationSuccess) {
embed.addFields({
name: "Migration Output",
@@ -259,6 +281,66 @@ export function getRunningMigrationsEmbed() {
);
}
export function getBuildingWebEmbed() {
return createInfoEmbed(
"🌐 Building web dashboard assets...\nThis may take a moment.",
"⏳ Building Web Dashboard"
);
}
export interface PostRestartProgress {
installDeps: boolean;
buildWeb: boolean;
runMigrations: boolean;
currentStep: "starting" | "install" | "build" | "migrate" | "done";
installDone?: boolean;
buildDone?: boolean;
migrateDone?: boolean;
}
export function getPostRestartProgressEmbed(progress: PostRestartProgress) {
const steps: string[] = [];
// Installation step
if (progress.installDeps) {
if (progress.currentStep === "install") {
steps.push("⏳ Installing dependencies...");
} else if (progress.installDone) {
steps.push("✅ Dependencies installed");
} else {
steps.push("⬚ Install dependencies");
}
}
// Web build step
if (progress.buildWeb) {
if (progress.currentStep === "build") {
steps.push("⏳ Building web dashboard...");
} else if (progress.buildDone) {
steps.push("✅ Web dashboard built");
} else {
steps.push("⬚ Build web dashboard");
}
}
// Migrations step
if (progress.runMigrations) {
if (progress.currentStep === "migrate") {
steps.push("⏳ Running migrations...");
} else if (progress.migrateDone) {
steps.push("✅ Migrations applied");
} else {
steps.push("⬚ Run migrations");
}
}
if (steps.length === 0) {
steps.push("⚡ Quick restart (no extra steps needed)");
}
return createInfoEmbed(steps.join("\n"), "🔄 Post-Update Tasks");
}
export function getRollbackSuccessEmbed(commit: string) {
return createSuccessEmbed(
`Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`,
@@ -272,3 +354,66 @@ export function getRollbackFailedEmbed(error: string) {
"❌ Rollback Failed"
);
}
// ============ Deploy Embeds ============
export function getDeployNotAvailableEmbed() {
return createErrorEmbed(
"Docker is not available in this environment.\n\n" +
"Deploy via Discord requires Docker socket access. " +
"Use the deploy script manually:\n" +
"```bash\ncd ~/Aurora && bash shared/scripts/deploy.sh\n```",
"❌ Deploy Unavailable"
);
}
export function getDeployCheckingEmbed() {
return createInfoEmbed(
"🔍 Checking for updates and preparing deployment...",
"🚀 Deploy"
);
}
export function getDeployProgressEmbed(step: string) {
return createInfoEmbed(
step,
"🚀 Deploying"
);
}
export function getDeploySuccessEmbed(previousCommit: string, newCommit: string, output: string) {
const embed = new EmbedBuilder()
.setTitle("🚀 Deployment Triggered")
.setColor(0x57F287)
.addFields(
{
name: "Version",
value: `\`${previousCommit}\`\`${newCommit}\``,
inline: false
},
{
name: "Status",
value: output || "Container rebuilding...",
inline: false
}
)
.setFooter({ text: "Container will restart shortly" })
.setTimestamp();
return embed;
}
export function getDeployNoChangesEmbed(currentCommit: string) {
return createSuccessEmbed(
`Already running the latest version.\n\n**Current:** \`${currentCommit}\``,
"✅ Up to Date"
);
}
export function getDeployErrorEmbed(error: string) {
return createErrorEmbed(
`Deployment failed:\n\`\`\`\n${truncate(error, 500)}\n\`\`\``,
"❌ Deploy Failed"
);
}

View File

@@ -1,6 +1,6 @@
import { ButtonInteraction } from "discord.js";
import { lootdropService } from "@shared/modules/economy/lootdrop.service";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
import { getLootdropClaimedMessage } from "./lootdrop.view";
export async function handleLootdropInteraction(interaction: ButtonInteraction) {

View File

@@ -1,7 +1,7 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export async function handleShopInteraction(interaction: ButtonInteraction) {
if (!interaction.customId.startsWith("shop_buy_")) return;

View File

@@ -4,7 +4,7 @@ import { config } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
export const handleFeedbackInteraction = async (interaction: Interaction) => {
// Handle select menu for choosing feedback type

View File

@@ -1,4 +1,13 @@
import { EmbedBuilder } from "discord.js";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
TextDisplayBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
MessageFlags
} from "discord.js";
/**
* Quest entry with quest details and progress
@@ -7,12 +16,33 @@ interface QuestEntry {
progress: number | null;
completedAt: Date | null;
quest: {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: any;
rewards: any;
};
}
/**
* Available quest interface
*/
interface AvailableQuest {
id: number;
name: string;
description: string | null;
rewards: any;
requirements: any;
}
// Color palette for containers
const COLORS = {
ACTIVE: 0x3498db, // Blue - in progress
AVAILABLE: 0x2ecc71, // Green - available
COMPLETED: 0xf1c40f // Gold - completed
};
/**
* Formats quest rewards object into a human-readable string
*/
@@ -20,35 +50,169 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
const rewardStr: string[] = [];
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
return rewardStr.join(", ");
return rewardStr.join(" ") || "None";
}
/**
* Returns the quest status display string
* Renders a simple progress bar
*/
function getQuestStatus(completedAt: Date | null): string {
return completedAt ? "✅ Completed" : "📝 In Progress";
function renderProgressBar(current: number, total: number, size: number = 10): string {
const percentage = Math.min(current / total, 1);
const progress = Math.round(size * percentage);
const empty = size - progress;
const progressText = "▰".repeat(progress);
const emptyText = "▱".repeat(empty);
return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`;
}
/**
* Creates an embed displaying a user's quest log
* Creates Components v2 containers for the quest list (active quests only)
*/
export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder {
const embed = new EmbedBuilder()
.setTitle("📜 Quest Log")
.setColor(0x3498db); // Blue
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
// Filter to only show in-progress quests (not completed)
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
const container = new ContainerBuilder()
.setAccentColor(COLORS.ACTIVE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
new TextDisplayBuilder().setContent("-# Your active quests")
);
if (activeQuests.length === 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*")
);
return [container];
}
activeQuests.forEach((entry) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
userQuests.forEach(entry => {
const status = getQuestStatus(entry.completedAt);
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
embed.addFields({
name: `${entry.quest.name} (${status})`,
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`,
inline: false
});
const requirements = entry.quest.requirements as { target?: number };
const target = requirements?.target || 1;
const progress = entry.progress || 0;
const progressBar = renderProgressBar(progress, target);
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
);
});
return embed;
return [container];
}
/**
* Creates Components v2 containers for available quests with inline accept buttons
*/
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
const container = new ContainerBuilder()
.setAccentColor(COLORS.AVAILABLE)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
new TextDisplayBuilder().setContent("-# Quests you can accept")
);
if (availableQuests.length === 0) {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent("*No new quests available at the moment.*")
);
return [container];
}
// Limit to 10 quests (5 action rows max with 2 added for navigation)
const questsToShow = availableQuests.slice(0, 10);
questsToShow.forEach((quest) => {
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
const rewards = quest.rewards as { xp?: number, balance?: number };
const rewardsText = formatQuestRewards(rewards);
const requirements = quest.requirements as { target?: number };
const target = requirements?.target || 1;
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`**${quest.name}**`),
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
);
// Add accept button inline within the container
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(`quest_accept:${quest.id}`)
.setLabel("Accept Quest")
.setStyle(ButtonStyle.Success)
.setEmoji("✅")
)
);
});
return [container];
}
/**
* Returns action rows for navigation only
*/
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
// Navigation row
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("quest_view_active")
.setLabel("📜 Active")
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'active'),
new ButtonBuilder()
.setCustomId("quest_view_available")
.setLabel("🗺️ Available")
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
.setDisabled(viewType === 'available')
);
return [navRow];
}
/**
* Creates Components v2 celebratory message for quest completion
*/
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
const rewardsText = formatQuestRewards({
xp: Number(rewards.xp),
balance: Number(rewards.balance)
});
const container = new ContainerBuilder()
.setAccentColor(COLORS.COMPLETED)
.addTextDisplayComponents(
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`)
)
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
.addTextDisplayComponents(
new TextDisplayBuilder().setContent(`📝 ${quest.description || "No description provided."}`),
new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`)
);
return [container];
}
/**
* Gets MessageFlags and allowedMentions for Components v2 messages
*/
export function getComponentsV2MessageFlags() {
return {
flags: MessageFlags.IsComponentsV2,
allowedMentions: { parse: [] as const }
};
}

View File

@@ -10,7 +10,7 @@ import {
import { tradeService } from "@shared/modules/trade/trade.service";
import { inventoryService } from "@shared/modules/inventory/inventory.service";
import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds";
import { UserError } from "@lib/errors";
import { UserError } from "@shared/lib/errors";
import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view";

View File

@@ -0,0 +1,116 @@
# Trivia - Components v2 Implementation
This trivia feature uses **Discord Components v2** for a premium visual experience.
## 🎨 Visual Features
### **Container with Accent Colors**
Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty:
- **🟢 Easy**: Green accent bar (`0x57F287`)
- **🟡 Medium**: Yellow accent bar (`0xFEE75C`)
- **🔴 Hard**: Red accent bar (`0xED4245`)
### **Modern Layout Components**
- **TextDisplay** - Rich markdown formatting for question text
- **Separator** - Visual spacing between sections
- **Container** - Groups all content with difficulty-based styling
### **Interactive Features**
**Give Up Button** - Players can forfeit if they're unsure
**Disabled Answer Buttons** - After answering, buttons show:
- ✅ Green for correct answer
- ❌ Red for user's incorrect answer
- Gray for other options
**Time Display** - Shows both relative time (`in 30s`) and seconds remaining
**Stakes Preview** - Clear display: `50 AU ➜ 100 AU`
## 📁 File Structure
```
bot/modules/trivia/
├── trivia.view.ts # Components v2 view functions
├── trivia.interaction.ts # Button interaction handler
└── README.md # This file
bot/commands/economy/
└── trivia.ts # /trivia slash command
```
## 🔧 Technical Details
### Components v2 Requirements
- Uses `MessageFlags.IsComponentsV2` flag
- No `embeds` or `content` fields (uses TextDisplay instead)
- Numeric component types:
- `1` - Action Row
- `2` - Button
- `10` - Text Display
- `14` - Separator
- `17` - Container
- Max 40 components per message (vs 5 for legacy)
### Button Styles
- **Secondary (2)**: Gray - Used for answer buttons
- **Success (3)**: Green - Used for "True" and correct answers
- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up"
## 🎮 User Experience Flow
1. User runs `/trivia`
2. Sees question in a Container with difficulty-based accent color
3. Can choose to:
- Select an answer (A/B/C/D or True/False)
- Give up using the 🏳️ button
4. After answering, sees result with:
- Disabled buttons showing correct/incorrect answers
- Container with result-based accent color (green/red/yellow)
- Reward or penalty information
## 🌟 Visual Examples
### Question Display
```
┌─[GREEN]─────────────────────────┐
│ # 🎯 Trivia Challenge │
│ 🟢 Easy • 📚 Geography │
│ ─────────────────────────── │
│ ### What is the capital of │
│ France? │
│ │
│ ⏱️ Time: in 30s (30s) │
│ 💰 Stakes: 50 AU ➜ 100 AU │
│ 👤 Player: Username │
└─────────────────────────────────┘
[🇦 A: Paris] [🇧 B: London]
[🇨 C: Berlin] [🇩 D: Madrid]
[🏳️ Give Up]
```
### Result Display (Correct)
```
┌─[GREEN]─────────────────────────┐
│ # 🎉 Correct Answer! │
│ ### What is the capital of │
│ France? │
│ ─────────────────────────── │
│ ✅ Your answer: Paris │
│ │
│ 💰 Reward: +100 AU │
│ │
│ 🏆 Great job! Keep it up! │
└─────────────────────────────────┘
[✅ A: Paris] [❌ B: London]
[❌ C: Berlin] [❌ D: Madrid]
(all buttons disabled)
```
## 🚀 Future Enhancements
Potential improvements:
- [ ] Thumbnail images based on trivia category
- [ ] Progress bar for time remaining
- [ ] Streak counter display
- [ ] Category-specific accent colors
- [ ] Media Gallery for image-based questions
- [ ] Leaderboard integration in results

View File

@@ -0,0 +1,129 @@
import { ButtonInteraction } from "discord.js";
import { triviaService } from "@shared/modules/trivia/trivia.service";
import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view";
import { UserError } from "@shared/lib/errors";
export async function handleTriviaInteraction(interaction: ButtonInteraction) {
const parts = interaction.customId.split('_');
// Check for "Give Up" button
if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') {
const sessionId = `${parts[2]}_${parts[3]}`;
const session = triviaService.getSession(sessionId);
if (!session) {
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership
if (session.userId !== interaction.user.id) {
await interaction.reply({
content: '❌ This isn\'t your trivia question!',
ephemeral: true
});
return;
}
await interaction.deferUpdate();
// Process as incorrect (user gave up)
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false);
// Show timeout view (since they gave up)
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
return;
}
// Handle answer button
if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') {
return;
}
const sessionId = `${parts[2]}_${parts[3]}`;
const answerIndexStr = parts[4];
if (!answerIndexStr) {
throw new UserError('Invalid answer format.');
}
const answerIndex = parseInt(answerIndexStr);
// Get session BEFORE deferring to check ownership
const session = triviaService.getSession(sessionId);
if (!session) {
// Session doesn't exist or expired
await interaction.reply({
content: '❌ This trivia question has expired or already been answered.',
ephemeral: true
});
return;
}
// Validate ownership BEFORE deferring
if (session.userId !== interaction.user.id) {
// Wrong user trying to answer - send ephemeral error
await interaction.reply({
content: '❌ This isn\'t your trivia question! Use `/trivia` to start your own game.',
ephemeral: true
});
return;
}
// Only defer if ownership is valid
await interaction.deferUpdate();
// Check timeout
if (new Date() > session.expiresAt) {
const { components, flags } = getTriviaTimeoutView(
session.question.question,
session.question.correctAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
// Clean up session
await triviaService.submitAnswer(sessionId, interaction.user.id, false);
return;
}
// Check if correct
const isCorrect = answerIndex === session.correctIndex;
const userAnswer = session.allAnswers[answerIndex];
// Process result
const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect);
// Update message with enhanced visual feedback
const { components, flags } = getTriviaResultView(
result,
session.question.question,
userAnswer,
session.allAnswers,
session.entryFee
);
await interaction.editReply({
components,
flags
});
}

View File

@@ -0,0 +1,336 @@
import { MessageFlags } from "discord.js";
import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service";
/**
* Get color based on difficulty level
*/
function getDifficultyColor(difficulty: string): number {
switch (difficulty.toLowerCase()) {
case 'easy':
return 0x57F287; // Green
case 'medium':
return 0xFEE75C; // Yellow
case 'hard':
return 0xED4245; // Red
default:
return 0x5865F2; // Blurple
}
}
/**
* Get emoji for difficulty level
*/
function getDifficultyEmoji(difficulty: string): string {
switch (difficulty.toLowerCase()) {
case 'easy':
return '🟢';
case 'medium':
return '🟡';
case 'hard':
return '🔴';
default:
return '⭐';
}
}
/**
* Generate Components v2 message for a trivia question
*/
export function getTriviaQuestionView(session: TriviaSession, username: string): {
components: any[];
flags: number;
} {
const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session;
// Calculate time remaining
const now = Date.now();
const timeLeft = Math.max(0, expiresAt.getTime() - now);
const secondsLeft = Math.floor(timeLeft / 1000);
const difficultyEmoji = getDifficultyEmoji(question.difficulty);
const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
const accentColor = getDifficultyColor(question.difficulty);
const components: any[] = [];
// Main Container with difficulty accent color
components.push({
type: 17, // Container
accent_color: accentColor,
components: [
// Title and metadata section
{
type: 10, // Text Display
content: `# 🎯 Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • 📚 ${question.category}`
},
// Separator
{
type: 14, // Separator
spacing: 1,
divider: true
},
// Question
{
type: 10, // Text Display
content: `### ${question.question}`
},
// Stats section
{
type: 14, // Separator
spacing: 1,
divider: false
},
{
type: 10, // Text Display
content: `⏱️ **Time:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${secondsLeft}s)\n💰 **Stakes:** ${entryFee} AU ➜ ${potentialReward} AU\n👤 **Player:** ${username}`
}
]
});
// Answer buttons
if (question.type === 'boolean') {
const trueIndex = allAnswers.indexOf('True');
const falseIndex = allAnswers.indexOf('False');
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${trueIndex}`,
label: 'True',
style: 3, // Success
emoji: { name: '✅' }
},
{
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${falseIndex}`,
label: 'False',
style: 4, // Danger
emoji: { name: '❌' }
}
]
});
} else {
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
const buttonRow: any = {
type: 1, // Action Row
components: []
};
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_answer_${sessionId}_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: 2, // Secondary
emoji: { name: emoji }
});
}
components.push(buttonRow);
}
// Give Up button in separate row
components.push({
type: 1, // Action Row
components: [
{
type: 2, // Button
custom_id: `trivia_giveup_${sessionId}`,
label: 'Give Up',
style: 4, // Danger
emoji: { name: '🏳️' }
}
]
});
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 result message
*/
export function getTriviaResultView(
result: TriviaResult,
question: string,
userAnswer?: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const { correct, reward, correctAnswer } = result;
const components: any[] = [];
if (correct) {
// Success container
components.push({
type: 17, // Container
accent_color: 0x57F287, // Green
components: [
{
type: 10, // Text Display
content: `# 🎉 Correct Answer!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `✅ **Your answer:** ${correctAnswer}\n\n💰 **Reward:** +${reward} AU\n\n🏆 Great job! Keep it up!`
}
]
});
} else {
const answerDisplay = userAnswer
? `❌ **Your answer:** ${userAnswer}\n✅ **Correct answer:** ${correctAnswer}`
: `✅ **Correct answer:** ${correctAnswer}`;
// Error container
components.push({
type: 17, // Container
accent_color: 0xED4245, // Red
components: [
{
type: 10, // Text Display
content: `# ❌ Incorrect Answer\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `${answerDisplay}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n📚 Better luck next time!`
}
]
});
}
// Show disabled buttons with visual feedback
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
const wasUserAnswer = answer === userAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_result_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary
emoji: { name: isCorrect ? '✅' : wasUserAnswer ? '❌' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
/**
* Generate Components v2 timeout message
*/
export function getTriviaTimeoutView(
question: string,
correctAnswer: string,
allAnswers?: string[],
entryFee: bigint = 0n
): {
components: any[];
flags: number;
} {
const components: any[] = [];
// Timeout container
components.push({
type: 17, // Container
accent_color: 0xFEE75C, // Yellow
components: [
{
type: 10, // Text Display
content: `# ⏱️ Time's Up!\n### ${question}`
},
{
type: 14, // Separator
spacing: 1,
divider: true
},
{
type: 10, // Text Display
content: `⏰ **You ran out of time!**\n✅ **Correct answer:** ${correctAnswer}\n\n💸 **Entry fee lost:** ${entryFee} AU\n\n⚡ Be faster next time!`
}
]
});
// Show disabled buttons with correct answer highlighted
if (allAnswers && allAnswers.length > 0) {
const buttonRow: any = {
type: 1, // Action Row
components: []
};
const labels = ['A', 'B', 'C', 'D'];
const emojis = ['🇦', '🇧', '🇨', '🇩'];
for (let i = 0; i < allAnswers.length && i < 4; i++) {
const label = labels[i];
const emoji = emojis[i];
const answer = allAnswers[i];
if (!label || !emoji || !answer) continue;
const isCorrect = answer === correctAnswer;
buttonRow.components.push({
type: 2, // Button
custom_id: `trivia_timeout_${i}`,
label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`,
style: isCorrect ? 3 : 2, // Success : Secondary
emoji: { name: isCorrect ? '✅' : emoji },
disabled: true
});
}
components.push(buttonRow);
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}

View File

@@ -3,7 +3,7 @@ import { config } from "@shared/lib/config";
import { getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@shared/modules/class/class.service";
import { userService } from "@shared/modules/user/user.service";
import { UserError } from "@/lib/errors";
import { UserError } from "@shared/lib/errors";
import { sendWebhookMessage } from "@/lib/webhookUtils";
export async function handleEnrollmentInteraction(interaction: ButtonInteraction) {

View File

@@ -9,12 +9,12 @@
"discord.js": "^14.25.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"zod": "^4.1.13",
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.7",
"postgres": "^3.4.7",
},
"peerDependencies": {
"typescript": "^5",

105
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,105 @@
# Production Docker Compose Configuration
# Usage: docker compose -f docker-compose.prod.yml up -d
#
# IMPORTANT: Database data is preserved in ./shared/db/data volume
services:
db:
image: postgres:17-alpine
container_name: aurora_db
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME}
volumes:
# Database data - persisted across container rebuilds
- ./shared/db/data:/var/lib/postgresql/data
- ./shared/db/log:/var/log/postgresql
networks:
- internal
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}" ]
interval: 10s
timeout: 5s
retries: 5
# Security: limit resources
deploy:
resources:
limits:
memory: 512M
socket-proxy:
image: tecnativa/docker-socket-proxy
container_name: socket_proxy
restart: unless-stopped
environment:
- CONTAINERS=1
- POST=1
- BUILD=1
- NETWORKS=1
- IMAGES=1 # Needed for pulling/pruning
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- internal
app:
container_name: aurora_app
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile.prod
target: production
image: aurora-app:latest
ports:
- "127.0.0.1:3000:3000"
# Volumes for bot-triggered deployments
volumes:
# Project directory - allows git pull and rebuild
- .:/app/deploy
# SSH Keys for git authentication
- ~/.ssh/aurora_bot_key:/home/bun/.ssh/id_ed25519:ro
- ~/.ssh/known_hosts:/home/bun/.ssh/known_hosts:ro
working_dir: /app
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_PORT=5432
- DB_HOST=db
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
# Deploy directory path for bot-triggered deployments
- DEPLOY_DIR=/app/deploy
- DOCKER_HOST=tcp://socket-proxy:2375
depends_on:
db:
condition: service_healthy
socket-proxy:
condition: service_started
networks:
- internal
- web
# Security: limit resources
deploy:
resources:
limits:
memory: 1G
# Logging configuration
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
internal:
driver: bridge
internal: true # No external access - DB isolated
web:
driver: bridge # App accessible from host (via reverse proxy)

View File

@@ -10,8 +10,8 @@ services:
# ports:
# - "127.0.0.1:${DB_PORT}:5432"
volumes:
# Host-mounted to preserve existing VPS data
- ./shared/db/data:/var/lib/postgresql/data
- ./shared/db/log:/var/log/postgresql
networks:
- internal
healthcheck:
@@ -23,17 +23,19 @@ services:
app:
container_name: aurora_app
restart: unless-stopped
image: aurora-app
build:
context: .
dockerfile: Dockerfile
target: development # Use development stage
working_dir: /app
ports:
- "127.0.0.1:3000:3000"
volumes:
# Mount source code for hot reloading
- .:/app
- /app/node_modules
- /app/web/node_modules
# Use named volumes for node_modules (prevents host overwrite + caches deps)
- app_node_modules:/app/node_modules
- web_node_modules:/app/web/node_modules
environment:
- HOST=0.0.0.0
- DB_USER=${DB_USER}
@@ -61,30 +63,23 @@ services:
studio:
container_name: aurora_studio
image: aurora-app
build:
context: .
dockerfile: Dockerfile
working_dir: /app
# Reuse the same built image as app (no duplicate builds!)
extends:
service: app
# Clear inherited ports from app and only expose studio port
ports:
!override
- "127.0.0.1:4983:4983"
volumes:
- .:/app
- /app/node_modules
- /app/web/node_modules
environment:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- DB_PORT=5432
- DB_HOST=db
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
depends_on:
db:
condition: service_healthy
networks:
- internal
command: bun run db:studio
# Override healthcheck since studio doesn't serve on port 3000
healthcheck:
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Disable restart for studio (it's an on-demand tool)
restart: "no"
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
networks:
internal:
@@ -92,3 +87,10 @@ networks:
internal: true # No external access
web:
driver: bridge # Can be accessed from host
volumes:
# Named volumes for node_modules caching
app_node_modules:
name: aurora_app_node_modules
web_node_modules:
name: aurora_web_node_modules

168
docs/main.md Normal file
View File

@@ -0,0 +1,168 @@
# Aurora - Discord RPG Bot
A comprehensive, feature-rich Discord RPG bot built with modern technologies using a monorepo architecture.
## Architecture Overview
Aurora uses a **Single Process Monolith** architecture that runs both the Discord bot and web dashboard in the same Bun process. This design maximizes performance by eliminating inter-process communication overhead and simplifies deployment to a single Docker container.
## Monorepo Structure
```
aurora-bot-discord/
├── bot/ # Discord bot implementation
│ ├── commands/ # Slash command implementations
│ ├── events/ # Discord event handlers
│ ├── lib/ # Bot core logic (BotClient, utilities)
│ └── index.ts # Bot entry point
├── web/ # React web dashboard
│ ├── src/ # React components and pages
│ │ ├── pages/ # Dashboard pages (Admin, Settings, Home)
│ │ ├── components/ # Reusable UI components
│ │ └── server.ts # Web server with API endpoints
│ └── build.ts # Vite build configuration
├── shared/ # Shared code between bot and web
│ ├── db/ # Database schema and Drizzle ORM
│ ├── lib/ # Utilities, config, logger, events
│ ├── modules/ # Domain services (economy, admin, quest)
│ └── config/ # Configuration files
├── docker-compose.yml # Docker services (app, db)
└── package.json # Root package manifest
```
## Main Application Parts
### 1. Discord Bot (`bot/`)
The bot is built with Discord.js v14 and handles all Discord-related functionality.
**Core Components:**
- **BotClient** (`bot/lib/BotClient.ts`): Central client that manages commands, events, and Discord interactions
- **Commands** (`bot/commands/`): Slash command implementations organized by category:
- `admin/`: Server management commands (config, prune, warnings, notes)
- `economy/`: Economy commands (balance, daily, pay, trade, trivia)
- `inventory/`: Item management commands
- `leveling/`: XP and level tracking
- `quest/`: Quest commands
- `user/`: User profile commands
- **Events** (`bot/events/`): Discord event handlers:
- `interactionCreate.ts`: Command interactions
- `messageCreate.ts`: Message processing
- `ready.ts`: Bot ready events
- `guildMemberAdd.ts`: New member handling
### 2. Web Dashboard (`web/`)
A React 19 + Bun web application for bot administration and monitoring.
**Key Pages:**
- **Home** (`/`): Dashboard overview with live statistics
- **Admin Overview** (`/admin/overview`): Real-time bot metrics
- **Admin Quests** (`/admin/quests`): Quest management interface
- **Settings** (`/settings/*`): Configuration pages for:
- General settings
- Economy settings
- Systems settings
- Roles settings
**Web Server Features:**
- Built with Bun's native HTTP server
- WebSocket support for real-time updates
- REST API endpoints for dashboard data
- SPA fallback for client-side routing
- Bun dev server with hot module replacement
### 3. Shared Core (`shared/`)
Shared code accessible by both bot and web applications.
**Database Layer (`shared/db/`):**
- **schema.ts**: Drizzle ORM schema definitions for:
- `users`: User profiles with economy data
- `items`: Item catalog with rarities and types
- `inventory`: User item holdings
- `transactions`: Economy transaction history
- `classes`: RPG class system
- `moderationCases`: Moderation logs
- `quests`: Quest definitions
**Modules (`shared/modules/`):**
- **economy/**: Economy service, lootdrops, daily rewards, trading
- **admin/**: Administrative actions (maintenance mode, cache clearing)
- **quest/**: Quest creation and tracking
- **dashboard/**: Dashboard statistics and real-time event bus
- **leveling/**: XP and leveling logic
**Utilities (`shared/lib/`):**
- `config.ts`: Application configuration management
- `logger.ts`: Structured logging system
- `env.ts`: Environment variable handling
- `events.ts`: Event bus for inter-module communication
- `constants.ts`: Application-wide constants
## Main Use-Cases
### For Discord Users
1. **Class System**: Users can join different RPG classes with unique roles
2. **Economy**:
- View balance and net worth
- Earn currency through daily rewards, trivia, and lootdrops
- Send payments to other users
3. **Trading**: Secure trading system between users
4. **Inventory Management**: Collect, use, and trade items with rarities
5. **Leveling**: XP-based progression system tied to activity
6. **Quests**: Complete quests for rewards
7. **Lootdrops**: Random currency drops in text channels
### For Server Administrators
1. **Bot Configuration**: Adjust economy rates, enable/disable features via dashboard
2. **Moderation Tools**:
- Warn, note, and track moderation cases
- Mass prune inactive members
- Role management
3. **Quest Management**: Create and manage server-specific quests
4. **Monitoring**:
- Real-time dashboard with live statistics
- Activity charts and event logs
- Economy leaderboards
### For Developers
1. **Single Process Architecture**: Easy debugging with unified runtime
2. **Type Safety**: Full TypeScript across all modules
3. **Testing**: Bun test framework with unit tests for core services
4. **Docker Support**: Production-ready containerization
5. **Remote Access**: SSH tunneling scripts for production debugging
## Technology Stack
| Layer | Technology |
| ---------------- | --------------------------------- |
| Runtime | Bun 1.0+ |
| Bot Framework | Discord.js 14.x |
| Web Framework | React 19 + Bun |
| Database | PostgreSQL 17 |
| ORM | Drizzle ORM |
| Styling | Tailwind CSS v4 + ShadCN/Radix UI |
| Validation | Zod |
| Containerization | Docker |
## Running the Application
```bash
# Database migrations
bun run migrate
# Production (Docker)
docker compose up
```
The bot and dashboard process run on port 3000 and are accessible at `http://localhost:3000`.

View File

@@ -1,5 +1,6 @@
{
"name": "app",
"version": "1.1.3",
"module": "bot/index.ts",
"type": "module",
"private": true,
@@ -16,11 +17,12 @@
"db:push": "docker compose run --rm app drizzle-kit push",
"db:push:local": "drizzle-kit push",
"dev": "bun --watch bot/index.ts",
"db:studio": "drizzle-kit studio --host 0.0.0.0",
"studio:remote": "bash shared/scripts/remote-studio.sh",
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
"remote": "bash shared/scripts/remote.sh",
"test": "bun test"
"logs": "bash shared/scripts/logs.sh",
"db:backup": "bash shared/scripts/db-backup.sh",
"test": "bun test",
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.84",

View File

@@ -13,7 +13,13 @@ import {
bigserial,
check
} from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm';
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
export type User = InferSelectModel<typeof users>;
export type Transaction = InferSelectModel<typeof transactions>;
export type ModerationCase = InferSelectModel<typeof moderationCases>;
export type Item = InferSelectModel<typeof items>;
export type Inventory = InferSelectModel<typeof inventory>;
// --- TABLES ---

View File

@@ -1,63 +0,0 @@
# Command Reference
This document lists all available slash commands in Aurora, categorized by their function.
## Economy
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/balance` | View your or another user's balance. | `user` (Optional): The user to check. | Everyone |
| `/daily` | Claim your daily currency reward and streak bonus. | None | Everyone |
| `/pay` | Transfer currency to another user. | `user` (Required): Recipient.<br>`amount` (Required): Amount to send. | Everyone |
| `/trade` | Start a trade session with another user. | `user` (Required): The user to trade with. | Everyone |
| `/exam` | Take your weekly exam to earn rewards based on XP gain. | None | Everyone |
## Inventory & Items
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/inventory` | View your or another user's inventory. | `user` (Optional): The user to check. | Everyone |
| `/use` | Use an item from your inventory. | `item` (Required): The item to use (Autocomplete). | Everyone |
## User & Social
| Command | Description | Options | Permissions |
|---|---|---|---|
| `/profile` | View your or another user's Student ID card. | `user` (Optional): The user to view. | Everyone |
| `/leaderboard` | View top players. | `type` (Required): 'Level / XP' or 'Balance'. | Everyone |
| `/feedback` | Submit feedback, bug reports, or suggestions. | None | Everyone |
| `/quests` | View your active quests. | None | Everyone |
## Admin
> [!IMPORTANT]
> These commands require Administrator permissions or specific roles as configured.
### General Management
| Command | Description | Options |
|---|---|---|
| `/config` | Manage bot configuration. | `group` (Req): Section.<br>`key` (Req): Setting.<br>`value` (Req): New value. |
| `/refresh` | Refresh commands or configuration cache. | `type`: 'Commands' or 'Config'. |
| `/update` | Update the bot from the repository. | None |
| `/features` | Enable/Disable system features. | `feature` (Req): Feature name.<br>`enabled` (Req): True/False. |
| `/webhook` | Send a message via webhook. | `payload` (Req): JSON payload. |
### Moderation
| Command | Description | Options |
|---|---|---|
| `/warn` | Warn a user. | `user` (Req): Target.<br>`reason` (Req): Reason. |
| `/warnings` | View active warnings for a user. | `user` (Req): Target. |
| `/clearwarning`| Clear a specific warning. | `case_id` (Req): Case ID. |
| `/case` | View details of a specific moderation case. | `case_id` (Req): Case ID. |
| `/cases` | View moderation history for a user. | `user` (Req): Target. |
| `/note` | Add a note to a user. | `user` (Req): Target.<br>`note` (Req): Content. |
| `/notes` | View notes for a user. | `user` (Req): Target. |
| `/prune` | Bulk delete messages. | `amount` (Req): Number (1-100). |
### Game Admin
| Command | Description | Options |
|---|---|---|
| `/create_item` | Create a new item in the database. | (Modal based interaction) |
| `/create_color`| Create a new color role. | `name` (Req): Role name.<br>`hex` (Req): Hex color code. |
| `/listing` | Manage shop listings (Admin view). | None (Context sensitive?) |
| `/terminal` | Control the terminal display channel. | `action`: 'setup', 'update', 'clear'. |

View File

@@ -1,160 +0,0 @@
# Configuration Guide
This document outlines the structure and available options for the `config/config.json` file. The configuration is validated using Zod schemas at runtime (see `src/lib/config.ts`).
## Core Structure
### Leveling
Configuration for the XP and leveling system.
| Field | Type | Description |
|-------|------|-------------|
| `base` | `number` | The base XP required for the first level. |
| `exponent` | `number` | The exponent used to calculate XP curves. |
| `chat.cooldownMs` | `number` | Time in milliseconds between XP gains from chat. |
| `chat.minXp` | `number` | Minimum XP awarded per message. |
| `chat.maxXp` | `number` | Maximum XP awarded per message. |
### Economy
Settings for currency, rewards, and transfers.
#### Daily
| Field | Type | Description |
|-------|------|-------------|
| `amount` | `integer` | Base amount granted by `/daily`. |
| `streakBonus` | `integer` | Bonus amount per streak day. |
| `weeklyBonus` | `integer` | Bonus amount for a 7-day streak. |
| `cooldownMs` | `number` | Cooldown period for the command (usually 24h). |
#### Transfers
| Field | Type | Description |
|-------|------|-------------|
| `allowSelfTransfer` | `boolean` | Whether users can transfer money to themselves. |
| `minAmount` | `integer` | Minimum amount required for a transfer. |
#### Exam
| Field | Type | Description |
|-------|------|-------------|
| `multMin` | `number` | Minimum multiplier for exam rewards. |
| `multMax` | `number` | Maximum multiplier for exam rewards. |
### Inventory
| Field | Type | Description |
|-------|------|-------------|
| `maxStackSize` | `integer` | Maximum count of a single item in one slot. |
| `maxSlots` | `number` | Total number of inventory slots available. |
### Lootdrop
Settings for the random chat loot drop events.
| Field | Type | Description |
|-------|------|-------------|
| `activityWindowMs` | `number` | Time window to track activity for spawning drops. |
| `minMessages` | `number` | Minimum messages required in window to trigger drop. |
| `spawnChance` | `number` | Probability (0-1) of a drop spawning when conditions met. |
| `cooldownMs` | `number` | Minimum time between loot drops. |
| `reward.min` | `number` | Minimum currency reward. |
| `reward.max` | `number` | Maximum currency reward. |
| `reward.currency` | `string` | The currency ID/Symbol used for rewards. |
### Roles
| Field | Type | Description |
|-------|------|-------------|
| `studentRole` | `string` | Discord Role ID for students. |
| `visitorRole` | `string` | Discord Role ID for visitors. |
| `colorRoles` | `string[]` | List of Discord Role IDs available as color roles. |
### Moderation
Automated moderation settings.
#### Prune
| Field | Type | Description |
|-------|------|-------------|
| `maxAmount` | `number` | Maximum messages to delete in one go. |
| `confirmThreshold` | `number` | Amount above which confirmation is required. |
| `batchSize` | `number` | Size of delete batches. |
| `batchDelayMs` | `number` | Delay between batches. |
#### Cases
| Field | Type | Description |
|-------|------|-------------|
| `dmOnWarn` | `boolean` | Whether to DM users when they are warned. |
| `logChannelId` | `string` | (Optional) Channel ID for moderation logs. |
| `autoTimeoutThreshold` | `number` | (Optional) Warn count to trigger auto-timeout. |
### System & Misc
| Field | Type | Description |
|-------|------|-------------|
| `commands` | `Object` | Map of command names (keys) to boolean (values) to enable/disable them. |
| `welcomeChannelId` | `string` | (Optional) Channel ID for welcome messages. |
| `welcomeMessage` | `string` | (Optional) Custom welcome message text. |
| `feedbackChannelId` | `string` | (Optional) Channel ID where feedback is posted. |
| `terminal.channelId` | `string` | (Optional) Channel ID for terminal display. |
| `terminal.messageId` | `string` | (Optional) Message ID for terminal display. |
## Example Config
```json
{
"leveling": {
"base": 100,
"exponent": 1.5,
"chat": {
"cooldownMs": 60000,
"minXp": 15,
"maxXp": 25
}
},
"economy": {
"daily": {
"amount": "100",
"streakBonus": "10",
"weeklyBonus": "500",
"cooldownMs": 86400000
},
"transfers": {
"allowSelfTransfer": false,
"minAmount": "10"
},
"exam": {
"multMin": 1.0,
"multMax": 2.0
}
},
"inventory": {
"maxStackSize": "99",
"maxSlots": 20
},
"lootdrop": {
"activityWindowMs": 300000,
"minMessages": 10,
"spawnChance": 0.05,
"cooldownMs": 3600000,
"reward": {
"min": 50,
"max": 150,
"currency": "CREDITS"
}
},
"commands": {
"example": true
},
"studentRole": "123456789012345678",
"visitorRole": "123456789012345678",
"colorRoles": [],
"moderation": {
"prune": {
"maxAmount": 100,
"confirmThreshold": 50,
"batchSize": 100,
"batchDelayMs": 1000
},
"cases": {
"dmOnWarn": true
}
}
}
```
> [!NOTE]
> Fields marked as `integer` or `bigint` in the types can often be provided as strings in the JSON to ensure precision, but the system handles parsing them.

View File

@@ -1,149 +0,0 @@
# Database Schema
This document outlines the database schema for the Aurora project. The database is PostgreSQL, managed via Drizzle ORM.
## Tables
### Users (`users`)
Stores user data, economy, and progression.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Discord User ID. |
| `class_id` | `bigint` | Foreign Key -> `classes.id`. |
| `username` | `varchar(255)` | User's Discord username. |
| `is_active` | `boolean` | Whether the user is active (default: true). |
| `balance` | `bigint` | User's currency balance. |
| `xp` | `bigint` | User's experience points. |
| `level` | `integer` | User's level. |
| `daily_streak` | `integer` | Current streak of daily command usage. |
| `settings` | `jsonb` | User-specific settings. |
| `created_at` | `timestamp` | Record creation time. |
| `updated_at` | `timestamp` | Last update time. |
### Classes (`classes`)
Available character classes.
| Column | Type | Description |
|---|---|---|
| `id` | `bigint` | Primary Key. Custom ID. |
| `name` | `varchar(255)` | Class name (Unique). |
| `balance` | `bigint` | Class bank balance (shared/flavor). |
| `role_id` | `varchar(255)` | Discord Role ID associated with the class. |
### Items (`items`)
Definitions of items available in the game.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. Auto-incrementing ID. |
| `name` | `varchar(255)` | Item name (Unique). |
| `description` | `text` | Item description. |
| `rarity` | `varchar(20)` | Common, Rare, etc. Default: 'Common'. |
| `type` | `varchar(50)` | MATERIAL, CONSUMABLE, EQUIPMENT, etc. |
| `usage_data` | `jsonb` | Effect data for consumables/usables. |
| `price` | `bigint` | Base value of the item. |
| `icon_url` | `text` | URL for the item's icon. |
| `image_url` | `text` | URL for the item's large image. |
### Inventory (`inventory`)
Items held by users.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `item_id` | `integer` | PK/FK -> `items.id`. |
| `quantity` | `bigint` | Amount held. Must be > 0. |
### Transactions (`transactions`)
Currency transaction history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. The user affecting the balance. |
| `related_user_id` | `bigint` | FK -> `users.id`. The other party (if any). |
| `amount` | `bigint` | Amount transferred. |
| `type` | `varchar(50)` | Transaction type identifier. |
| `description` | `text` | Human-readable description. |
| `created_at` | `timestamp` | Time of transaction. |
### Item Transactions (`item_transactions`)
Item flow history.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `user_id` | `bigint` | FK -> `users.id`. |
| `related_user_id` | `bigint` | FK -> `users.id`. |
| `item_id` | `integer` | FK -> `items.id`. |
| `quantity` | `bigint` | Amount gained (+) or lost (-). |
| `type` | `varchar(50)` | TRADE, SHOP_BUY, DROP, etc. |
| `description` | `text` | Description. |
| `created_at` | `timestamp` | Time of transaction. |
### Quests (`quests`)
Quest definitions.
| Column | Type | Description |
|---|---|---|
| `id` | `serial` | Primary Key. |
| `name` | `varchar(255)` | Quest title. |
| `description` | `text` | Quest text. |
| `trigger_event` | `varchar(50)` | Event that triggers progress checks. |
| `requirements` | `jsonb` | Completion criteria. |
| `rewards` | `jsonb` | Rewards for completion. |
### User Quests (`user_quests`)
User progress on quests.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `quest_id` | `integer` | PK/FK -> `quests.id`. |
| `progress` | `integer` | Current progress value. |
| `completed_at` | `timestamp` | Completion time (null if active). |
### User Timers (`user_timers`)
Generic timers for cooldowns, temporary effects, etc.
| Column | Type | Description |
|---|---|---|
| `user_id` | `bigint` | PK/FK -> `users.id`. |
| `type` | `varchar(50)` | PK. Timer type (COOLDOWN, EFFECT, ACCESS). |
| `key` | `varchar(100)` | PK. specific ID (e.g. 'daily'). |
| `expires_at` | `timestamp` | When the timer expires. |
| `metadata` | `jsonb` | Extra data. |
### Lootdrops (`lootdrops`)
Active chat loot drop events.
| Column | Type | Description |
|---|---|---|
| `message_id` | `varchar(255)` | Primary Key. Discord Message ID. |
| `channel_id` | `varchar(255)` | Discord Channel ID. |
| `reward_amount` | `integer` | Currency amount. |
| `currency` | `varchar(50)` | Currency type constant. |
| `claimed_by` | `bigint` | FK -> `users.id`. Null if unclaimed. |
| `created_at` | `timestamp` | Spawn time. |
| `expires_at` | `timestamp` | Despawn time. |
### Moderation Cases (`moderation_cases`)
History of moderation actions.
| Column | Type | Description |
|---|---|---|
| `id` | `bigserial` | Primary Key. |
| `case_id` | `varchar(50)` | Unique friendly ID. |
| `type` | `varchar(20)` | warn, timeout, kick, ban, etc. |
| `user_id` | `bigint` | Target user ID. |
| `username` | `varchar(255)` | Target username snapshot. |
| `moderator_id` | `bigint` | Acting moderator ID. |
| `moderator_name` | `varchar(255)` | Moderator username snapshot. |
| `reason` | `text` | Reason for action. |
| `metadata` | `jsonb` | Extra data. |
| `active` | `boolean` | Is this case active? |
| `created_at` | `timestamp` | Creation time. |
| `resolved_at` | `timestamp` | Resolution/Expiration time. |
| `resolved_by` | `bigint` | User ID who resolved it. |
| `resolved_reason` | `text` | Reason for resolution. |

View File

@@ -1,127 +0,0 @@
# Lootbox Creation Guide
Currently, the Item Wizard does not support creating **Lootbox** items directly. Instead, they must be inserted manually into the database. This guide details the required JSON structure for the `LOOTBOX` effect.
## Item Structure
To create a lootbox, you need to insert a row into the `items` table. The critical part is the `usageData` JSON column.
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [ ... ]
}
]
}
```
## Loot Table Structure
The `pool` property is an array of `LootTableItem` objects. A random item is selected based on the total `weight` of all items in the pool.
| Field | Type | Description |
|-------|------|-------------|
| `type` | `string` | One of: `CURRENCY`, `ITEM`, `XP`, `NOTHING`. |
| `weight` | `number` | The relative probability weight of this outcome. |
| `message` | `string` | (Optional) Custom message to display when this outcome is selected. |
### Outcome Types
#### 1. Currency
Gives the user coins.
```json
{
"type": "CURRENCY",
"weight": 50,
"amount": 100, // Fixed amount OR
"minAmount": 50, // Minimum random amount
"maxAmount": 150 // Maximum random amount
}
```
#### 2. XP
Gives the user Experience Points.
```json
{
"type": "XP",
"weight": 30,
"amount": 500 // Fixed amount OR range (minAmount/maxAmount)
}
```
#### 3. Item
Gives the user another item (by ID).
```json
{
"type": "ITEM",
"weight": 10,
"itemId": 42, // The ID of the item to give
"amount": 1 // (Optional) Quantity to give, default 1
}
```
#### 4. Nothing
An empty roll.
```json
{
"type": "NOTHING",
"weight": 10,
"message": "The box was empty! Better luck next time."
}
```
## Complete Example
Here is a full SQL insert example (using a hypothetical SQL client or Drizzle studio) for a "Basic Lootbox":
**Name**: Basic Lootbox
**Type**: CONSUMABLE
**Effect**:
- 50% chance for 100-200 Coins
- 30% chance for 500 XP
- 10% chance for Item ID 5 (e.g. Rare Gem)
- 10% chance for Nothing
**JSON for `usageData`**:
```json
{
"consume": true,
"effects": [
{
"type": "LOOTBOX",
"pool": [
{
"type": "CURRENCY",
"weight": 50,
"minAmount": 100,
"maxAmount": 200
},
{
"type": "XP",
"weight": 30,
"amount": 500
},
{
"type": "ITEM",
"weight": 10,
"itemId": 5,
"amount": 1,
"message": "Startstruck! You found a Rare Gem!"
},
{
"type": "NOTHING",
"weight": 10,
"message": "It's empty..."
}
]
}
]
}
```

View File

@@ -1,72 +0,0 @@
# Aurora Module Structure Guide
This guide documents the standard module organization patterns used in the Aurora codebase. Following these patterns ensures consistency, maintainability, and clear separation of concerns.
## Module Anatomy
A typical module in `@modules/` is organized into several files, each with a specific responsibility.
Example: `trade` module
- `trade.service.ts`: Business logic and data access.
- `trade.view.ts`: Discord UI components (embeds, modals, select menus).
- `trade.interaction.ts`: Handler for interaction events (buttons, modals, etc.).
- `trade.types.ts`: TypeScript interfaces and types.
- `trade.service.test.ts`: Unit tests for the service logic.
## File Responsibilities
### 1. Service (`*.service.ts`)
The core of the module. It contains the business logic, database interactions (using Drizzle), and state management.
- **Rules**:
- Export a singleton instance: `export const tradeService = new TradeService();`
- Should not contain Discord-specific rendering logic (return data, not embeds).
- Throw `UserError` for validation issues that should be shown to the user.
### 2. View (`*.view.ts`)
Handles the creation of Discord-specific UI elements like `EmbedBuilder`, `ActionRowBuilder`, and `ModalBuilder`.
- **Rules**:
- Focus on formatting and presentation.
- Takes raw data (from services) and returns Discord components.
### 3. Interaction Handler (`*.interaction.ts`)
The entry point for Discord component interactions (buttons, select menus, modals).
- **Rules**:
- Export a single handler function: `export async function handleTradeInteraction(interaction: Interaction) { ... }`
- Routes internal `customId` patterns to specific logic.
- Relies on `ComponentInteractionHandler` for centralized error handling.
- **No local try-catch** for standard validation errors; let them bubble up as `UserError`.
### 4. Types (`*.types.ts`)
Central location for module-specific TypeScript types and constants.
- **Rules**:
- Define interfaces for complex data structures.
- Use enums or literal types for states and custom IDs.
## Interaction Routing
All interaction handlers must be registered in `src/lib/interaction.routes.ts`.
```typescript
{
predicate: (i) => i.customId.startsWith("module_"),
handler: () => import("@/modules/module/module.interaction"),
method: 'handleModuleInteraction'
}
```
## Error Handling Standards
Aurora uses a centralized error handling pattern in `ComponentInteractionHandler`.
1. **UserError**: Use this for validation errors or issues the user can fix (e.g., "Insufficient funds").
- `throw new UserError("You need more coins!");`
2. **SystemError / Generic Error**: Use this for unexpected system failures.
- These are logged to the console/logger and show a generic "Unexpected error" message to the user.
## Naming Conventions
- **Directory Name**: Lowercase, singular (e.g., `trade`, `inventory`).
- **File Names**: `moduleName.type.ts` (e.g., `trade.service.ts`).
- **Class Names**: PascalCase (e.g., `TradeService`).
- **Service Instances**: camelCase (e.g., `tradeService`).
- **Interaction Method**: `handle[ModuleName]Interaction`.

View File

@@ -1,3 +1,4 @@
import { jsonReplacer } from './utils';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
@@ -69,6 +70,14 @@ export interface GameConfigType {
autoTimeoutThreshold?: number;
};
};
trivia: {
entryFee: bigint;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
};
system: Record<string, any>;
}
@@ -162,6 +171,21 @@ const configSchema = z.object({
dmOnWarn: true
}
}),
trivia: z.object({
entryFee: bigIntSchema,
rewardMultiplier: z.number().min(0).max(10),
timeoutSeconds: z.number().min(5).max(300),
cooldownMs: z.number().min(0),
categories: z.array(z.number()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'),
}).default({
entryFee: 50n,
rewardMultiplier: 1.8,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: 'random'
}),
system: z.record(z.string(), z.any()).default({}),
});
@@ -191,14 +215,7 @@ export function saveConfig(newConfig: unknown) {
// Validate and transform input
const validatedConfig = configSchema.parse(newConfig);
const replacer = (key: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig();
}

View File

@@ -7,6 +7,7 @@ export enum TimerType {
EFFECT = 'EFFECT',
ACCESS = 'ACCESS',
EXAM_SYSTEM = 'EXAM_SYSTEM',
TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN',
}
export enum EffectType {
@@ -30,6 +31,8 @@ export enum TransactionType {
TRADE_IN = 'TRADE_IN',
TRADE_OUT = 'TRADE_OUT',
QUEST_REWARD = 'QUEST_REWARD',
TRIVIA_ENTRY = 'TRIVIA_ENTRY',
TRIVIA_WIN = 'TRIVIA_WIN',
}
export enum ItemTransactionType {
@@ -63,3 +66,28 @@ export enum LootType {
XP = 'XP',
ITEM = 'ITEM',
}
export enum TriviaCategory {
GENERAL_KNOWLEDGE = 9,
BOOKS = 10,
FILM = 11,
MUSIC = 12,
VIDEO_GAMES = 15,
SCIENCE_NATURE = 17,
COMPUTERS = 18,
MATHEMATICS = 19,
MYTHOLOGY = 20,
SPORTS = 21,
GEOGRAPHY = 22,
HISTORY = 23,
POLITICS = 24,
ART = 25,
ANIMALS = 27,
ANIME_MANGA = 31,
}
export const BRANDING = {
COLOR: 0x00d4ff as const,
FOOTER_TEXT: 'Aurora' as const,
};

View File

@@ -7,6 +7,7 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1, "Database URL is required"),
PORT: z.coerce.number().default(3000),
HOST: z.string().default("127.0.0.1"),
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
});
const parsedEnv = envSchema.safeParse(process.env);

24
shared/lib/events.ts Normal file
View File

@@ -0,0 +1,24 @@
import { EventEmitter } from "node:events";
/**
* Global system event bus for cross-module communication.
* Used primarily for real-time dashboard updates.
*/
class SystemEventEmitter extends EventEmitter { }
export const systemEvents = new SystemEventEmitter();
export const EVENTS = {
DASHBOARD: {
STATS_UPDATE: "dashboard:stats_update",
NEW_EVENT: "dashboard:new_event",
},
ACTIONS: {
RELOAD_COMMANDS: "actions:reload_commands",
CLEAR_CACHE: "actions:clear_cache",
MAINTENANCE_MODE: "actions:maintenance_mode",
},
QUEST: {
COMPLETED: "quest:completed",
}
} as const;

118
shared/lib/logger.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test";
import { logger } from "./logger";
import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
describe("Logger", () => {
const logDir = join(process.cwd(), "logs");
const logFile = join(logDir, "error.log");
beforeAll(() => {
// Cleanup if exists
try {
if (existsSync(logFile)) unlinkSync(logFile);
} catch (e) {}
});
test("should log info messages to console with correct format", () => {
const spy = spyOn(console, "log");
const message = "Formatting test";
logger.info("system", message);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toBeDefined();
if (callArgs) {
// Strict regex check for ISO timestamp and format
const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/;
expect(callArgs).toMatch(regex);
}
spy.mockRestore();
});
test("should write error logs to file with stack trace", async () => {
const errorMessage = "Test error message";
const testError = new Error("Source error");
logger.error("system", errorMessage, testError);
// Polling for file write instead of fixed timeout
let content = "";
for (let i = 0; i < 20; i++) {
if (existsSync(logFile)) {
content = readFileSync(logFile, "utf-8");
if (content.includes("Source error")) break;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/);
expect(content).toContain("Stack Trace:");
expect(content).toContain("Error: Source error");
expect(content).toContain("logger.test.ts");
});
test("should handle log directory creation failures gracefully", async () => {
const consoleSpy = spyOn(console, "error");
// We trigger an error by trying to use a path that is a file where a directory should be
const triggerFile = join(process.cwd(), "logs_fail_trigger");
try {
writeFileSync(triggerFile, "not a directory");
// Manually override paths for this test instance
const originalLogDir = (logger as any).logDir;
const originalLogPath = (logger as any).errorLogPath;
(logger as any).logDir = triggerFile;
(logger as any).errorLogPath = join(triggerFile, "error.log");
(logger as any).initialized = false;
logger.error("system", "This should fail directory creation");
// Wait for async initialization attempt
await new Promise(resolve => setTimeout(resolve, 100));
expect(consoleSpy).toHaveBeenCalled();
expect(consoleSpy.mock.calls.some(call =>
String(call[0]).includes("Failed to initialize logger directory")
)).toBe(true);
// Reset logger state
(logger as any).logDir = originalLogDir;
(logger as any).errorLogPath = originalLogPath;
(logger as any).initialized = false;
} finally {
if (existsSync(triggerFile)) unlinkSync(triggerFile);
consoleSpy.mockRestore();
}
});
test("should include complex data objects in logs", () => {
const spy = spyOn(console, "log");
const data = { userId: "123", tags: ["test"] };
logger.info("bot", "Message with data", data);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toBeDefined();
if (callArgs) {
expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`);
}
spy.mockRestore();
});
test("should handle circular references in data objects", () => {
const spy = spyOn(console, "log");
const data: any = { name: "circular" };
data.self = data;
logger.info("bot", "Circular test", data);
expect(spy).toHaveBeenCalled();
const callArgs = spy.mock.calls[0]?.[0];
expect(callArgs).toContain("[Circular]");
spy.mockRestore();
});
});

162
shared/lib/logger.ts Normal file
View File

@@ -0,0 +1,162 @@
import { join, resolve } from "path";
import { appendFile, mkdir, stat } from "fs/promises";
import { existsSync } from "fs";
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const LogLevelNames = {
[LogLevel.DEBUG]: "DEBUG",
[LogLevel.INFO]: "INFO",
[LogLevel.WARN]: "WARN",
[LogLevel.ERROR]: "ERROR",
};
export type LogSource = "bot" | "web" | "shared" | "system";
export interface LogEntry {
timestamp: string;
level: string;
source: LogSource;
message: string;
data?: any;
stack?: string;
}
class Logger {
private logDir: string;
private errorLogPath: string;
private initialized: boolean = false;
private initPromise: Promise<void> | null = null;
constructor() {
// Use resolve with __dirname or process.cwd() but make it more robust
// Since this is in shared/lib/, we can try to find the project root
// For now, let's stick to a resolved path from process.cwd() or a safer alternative
this.logDir = resolve(process.cwd(), "logs");
this.errorLogPath = join(this.logDir, "error.log");
}
private async ensureInitialized() {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
try {
await mkdir(this.logDir, { recursive: true });
this.initialized = true;
} catch (err: any) {
if (err.code === "EEXIST" || err.code === "ENOTDIR") {
try {
const stats = await stat(this.logDir);
if (stats.isDirectory()) {
this.initialized = true;
return;
}
} catch (statErr) {}
}
console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err);
} finally {
this.initPromise = null;
}
})();
return this.initPromise;
}
private safeStringify(data: any): string {
try {
return JSON.stringify(data);
} catch (err) {
const seen = new WeakSet();
return JSON.stringify(data, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
});
}
}
private formatMessage(entry: LogEntry): string {
const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : "";
const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : "";
return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`;
}
private async writeToErrorLog(formatted: string) {
await this.ensureInitialized();
try {
await appendFile(this.errorLogPath, formatted + "\n");
} catch (err) {
console.error("[SYSTEM] Failed to write to error log file:", err);
}
}
private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) {
const timestamp = new Date().toISOString();
const levelName = LogLevelNames[level];
const entry: LogEntry = {
timestamp,
level: levelName,
source,
message,
};
if (level === LogLevel.ERROR && errorOrData instanceof Error) {
entry.stack = errorOrData.stack;
entry.message = `${message}: ${errorOrData.message}`;
} else if (errorOrData !== undefined) {
entry.data = errorOrData;
}
const formatted = this.formatMessage(entry);
// Print to console
switch (level) {
case LogLevel.DEBUG:
console.debug(formatted);
break;
case LogLevel.INFO:
console.log(formatted);
break;
case LogLevel.WARN:
console.warn(formatted);
break;
case LogLevel.ERROR:
console.error(formatted);
break;
}
// Persistent error logging
if (level === LogLevel.ERROR) {
this.writeToErrorLog(formatted).catch(() => {
// Silently fail to avoid infinite loops
});
}
}
debug(source: LogSource, message: string, data?: any) {
this.log(LogLevel.DEBUG, source, message, data);
}
info(source: LogSource, message: string, data?: any) {
this.log(LogLevel.INFO, source, message, data);
}
warn(source: LogSource, message: string, data?: any) {
this.log(LogLevel.WARN, source, message, data);
}
error(source: LogSource, message: string, error?: any) {
this.log(LogLevel.ERROR, source, message, error);
}
}
export const logger = new Logger();

View File

@@ -9,3 +9,42 @@ import type { Command } from "./types";
export function createCommand(command: Command): Command {
return command;
}
/**
* JSON Replacer function for serialization
* Handles safe serialization of BigInt values to strings
*/
export const jsonReplacer = (_key: string, value: unknown): unknown => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
/**
* Deep merge utility
*/
export function deepMerge(target: any, source: any): any {
if (typeof target !== 'object' || target === null) {
return source;
}
if (typeof source !== 'object' || source === null) {
return source;
}
const output = { ...target };
Object.keys(source).forEach(key => {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
return output;
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
import { actionService } from "./action.service";
import { systemEvents, EVENTS } from "@shared/lib/events";
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
describe("ActionService", () => {
beforeEach(() => {
// Clear any previous mock state
mock.restore();
});
/**
* Test Case: Command Reload
* Requirement: Emits event and records to dashboard
*/
test("reloadCommands should emit RELOAD_COMMANDS event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.reloadCommands();
expect(result.success).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.RELOAD_COMMANDS);
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "info",
message: "Admin: Triggered command reload"
}));
});
/**
* Test Case: Cache Clearance
* Requirement: Emits event and records to dashboard
*/
test("clearCache should emit CLEAR_CACHE event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.clearCache();
expect(result.success).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.CLEAR_CACHE);
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "info",
message: "Admin: Triggered cache clearance"
}));
});
/**
* Test Case: Maintenance Mode Toggle
* Requirement: Emits event with correct payload and records to dashboard with warning type
*/
test("toggleMaintenanceMode should emit MAINTENANCE_MODE event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.toggleMaintenanceMode(true, "Test Reason");
expect(result.success).toBe(true);
expect(result.enabled).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Test Reason" });
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "warn",
message: "Admin: Maintenance mode ENABLED (Test Reason)"
}));
});
});

View File

@@ -0,0 +1,53 @@
import { systemEvents, EVENTS } from "@shared/lib/events";
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
/**
* Service to handle administrative actions triggered from the dashboard.
* These actions are broadcasted to the bot via the system event bus.
*/
export const actionService = {
/**
* Triggers a reload of all bot commands.
*/
reloadCommands: async () => {
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
await dashboardService.recordEvent({
type: "info",
message: "Admin: Triggered command reload",
icon: "♻️"
});
return { success: true, message: "Command reload triggered" };
},
/**
* Triggers a clearance of internal bot caches.
*/
clearCache: async () => {
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await dashboardService.recordEvent({
type: "info",
message: "Admin: Triggered cache clearance",
icon: "🧹"
});
return { success: true, message: "Cache clearance triggered" };
},
/**
* Toggles maintenance mode for the bot.
*/
toggleMaintenanceMode: async (enabled: boolean, reason?: string) => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled, reason });
await dashboardService.recordEvent({
type: enabled ? "warn" : "info",
message: `Admin: Maintenance mode ${enabled ? "ENABLED" : "DISABLED"}${reason ? ` (${reason})` : ""}`,
icon: "🛠️"
});
return { success: true, enabled, message: `Maintenance mode ${enabled ? "enabled" : "disabled"}` };
}
};

View File

@@ -1,5 +1,4 @@
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
import * as fs from "fs/promises";
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
// Mock child_process BEFORE importing the service
const mockExec = mock((cmd: string, callback?: any) => {
@@ -8,23 +7,32 @@ const mockExec = mock((cmd: string, callback?: any) => {
return { unref: () => { } };
}
if (cmd.includes("git rev-parse")) {
callback(null, { stdout: "main\n" });
// Simulate successful command execution
let stdout = "";
if (cmd.includes("git rev-parse --abbrev-ref")) {
stdout = "main\n";
} else if (cmd.includes("git rev-parse --short")) {
stdout = "abc1234\n";
} else if (cmd.includes("git rev-parse HEAD")) {
stdout = "abc1234567890\n";
} else if (cmd.includes("git fetch")) {
callback(null, { stdout: "" });
stdout = "";
} else if (cmd.includes("git log")) {
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2";
} else if (cmd.includes("git diff")) {
callback(null, { stdout: "package.json\nsrc/index.ts" });
stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts";
} else if (cmd.includes("git reset")) {
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
stdout = "HEAD is now at abcdef Update 1";
} else if (cmd.includes("bun install")) {
callback(null, { stdout: "Installed dependencies" });
stdout = "Installed dependencies";
} else if (cmd.includes("drizzle-kit migrate")) {
callback(null, { stdout: "Migrations applied" });
} else {
callback(null, { stdout: "" });
stdout = "Migrations applied";
} else if (cmd.includes("bun run build")) {
stdout = "Build completed";
}
callback(null, stdout, "");
});
mock.module("child_process", () => ({
@@ -32,9 +40,9 @@ mock.module("child_process", () => ({
}));
// Mock fs/promises
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
const mockUnlink = mock((path: string) => Promise.resolve());
const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve());
const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}"));
const mockUnlink = mock((_path: string) => Promise.resolve());
mock.module("fs/promises", () => ({
writeFile: mockWriteFile,
@@ -43,9 +51,9 @@ mock.module("fs/promises", () => ({
}));
// Mock view module to avoid import issues
mock.module("./update.view", () => ({
getPostRestartEmbed: () => ({ title: "Update Complete" }),
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
mock.module("@/modules/admin/update.view", () => ({
getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }),
getPostRestartProgressEmbed: () => ({ title: "Progress..." }),
}));
describe("UpdateService", () => {
@@ -72,7 +80,8 @@ describe("UpdateService", () => {
expect(result.hasUpdates).toBe(true);
expect(result.branch).toBe("main");
expect(result.log).toContain("Update 1");
expect(result.commits.length).toBeGreaterThan(0);
expect(result.commits[0].message).toContain("Update 1");
});
test("should call git rev-parse, fetch, and log commands", async () => {
@@ -83,43 +92,82 @@ describe("UpdateService", () => {
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
});
test("should include requirements in the response", async () => {
const result = await UpdateService.checkForUpdates();
expect(result.requirements).toBeDefined();
expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock
expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock
expect(result.requirements.changedFiles).toContain("package.json");
});
});
describe("performUpdate", () => {
test("should run git reset --hard with correct branch", async () => {
await UpdateService.performUpdate("main");
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("git reset --hard origin/main");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true);
});
});
describe("checkUpdateRequirements", () => {
describe("checkUpdateRequirements (deprecated)", () => {
test("should detect package.json and schema.ts changes", async () => {
const result = await UpdateService.checkUpdateRequirements("main");
expect(result.needsInstall).toBe(true);
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
expect(result.needsRootInstall).toBe(true);
expect(result.needsMigrations).toBe(true);
expect(result.error).toBeUndefined();
});
test("should call git diff with correct branch", async () => {
await UpdateService.checkUpdateRequirements("develop");
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true);
});
});
describe("installDependencies", () => {
test("should run bun install and return output", async () => {
const output = await UpdateService.installDependencies();
test("should run bun install for root only", async () => {
const output = await UpdateService.installDependencies({ root: true, web: false });
expect(output).toBe("Installed dependencies");
const lastCall = mockExec.mock.lastCall;
expect(lastCall![0]).toBe("bun install");
expect(output).toContain("Root");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
});
test("should run bun install for both root and web in parallel", async () => {
const output = await UpdateService.installDependencies({ root: true, web: true });
expect(output).toContain("Root");
expect(output).toContain("Web");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true);
});
});
describe("categorizeChanges", () => {
test("should categorize files correctly", () => {
const files = [
"bot/commands/admin/update.ts",
"bot/modules/admin/update.view.ts",
"web/src/components/Button.tsx",
"shared/lib/utils.ts",
"package.json",
"drizzle/0001_migration.sql"
];
const categories = UpdateService.categorizeChanges(files);
expect(categories["Commands"]).toBe(1);
expect(categories["Modules"]).toBe(1);
expect(categories["Web Dashboard"]).toBe(1);
expect(categories["Library"]).toBe(1);
expect(categories["Dependencies"]).toBe(1);
expect(categories["Database"]).toBe(1);
});
});
@@ -130,7 +178,10 @@ describe("UpdateService", () => {
userId: "456",
timestamp: Date.now(),
runMigrations: true,
installDependencies: false
installDependencies: false,
buildWebAssets: false,
previousCommit: "abc1234",
newCommit: "def5678"
};
await UpdateService.prepareRestartContext(context);
@@ -143,6 +194,39 @@ describe("UpdateService", () => {
});
});
describe("saveRollbackPoint", () => {
test("should save current commit hash to file", async () => {
const commit = await UpdateService.saveRollbackPoint();
expect(commit).toBeTruthy();
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall![0]).toContain("rollback_commit");
});
});
describe("hasRollbackPoint", () => {
test("should return true when rollback file exists", async () => {
mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123"));
// Clear cache first
(UpdateService as any).rollbackPointExists = null;
const result = await UpdateService.hasRollbackPoint();
expect(result).toBe(true);
});
test("should return false when rollback file does not exist", async () => {
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
// Clear cache first
(UpdateService as any).rollbackPointExists = null;
const result = await UpdateService.hasRollbackPoint();
expect(result).toBe(false);
});
});
describe("triggerRestart", () => {
test("should use RESTART_COMMAND env var when set", async () => {
const originalEnv = process.env.RESTART_COMMAND;
@@ -150,24 +234,19 @@ describe("UpdateService", () => {
await UpdateService.triggerRestart();
const lastCall = mockExec.mock.lastCall;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toBe("pm2 restart bot");
const calls = mockExec.mock.calls.map((c: any) => c[0]);
expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true);
process.env.RESTART_COMMAND = originalEnv;
});
test("should write to trigger file when no env var", async () => {
test("should call process.exit when no env var is set", async () => {
const originalEnv = process.env.RESTART_COMMAND;
delete process.env.RESTART_COMMAND;
// Just verify it doesn't throw - actual process.exit is mocked by setTimeout
await UpdateService.triggerRestart();
expect(mockWriteFile).toHaveBeenCalled();
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
expect(lastCall).toBeDefined();
expect(lastCall![0]).toContain("restart_trigger");
process.env.RESTART_COMMAND = originalEnv;
});
});
@@ -181,7 +260,7 @@ describe("UpdateService", () => {
const createMockChannel = () => ({
isSendable: () => true,
send: mock(() => Promise.resolve())
send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }))
});
test("should ignore stale context (>10 mins old)", async () => {
@@ -190,7 +269,10 @@ describe("UpdateService", () => {
userId: "456",
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
runMigrations: true,
installDependencies: true
installDependencies: true,
buildWebAssets: false,
previousCommit: "abc",
newCommit: "def"
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
@@ -227,7 +309,10 @@ describe("UpdateService", () => {
userId: "456",
timestamp: Date.now(),
runMigrations: false,
installDependencies: false
installDependencies: false,
buildWebAssets: false,
previousCommit: "abc",
newCommit: "def"
};
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
@@ -236,7 +321,7 @@ describe("UpdateService", () => {
const { TextChannel } = await import("discord.js");
const mockChannel = Object.create(TextChannel.prototype);
mockChannel.isSendable = () => true;
mockChannel.send = mock(() => Promise.resolve());
mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }));
const mockClient = createMockClient(mockChannel);

View File

@@ -1,8 +1,9 @@
import { exec } from "child_process";
import { exec, type ExecException } from "child_process";
import { promisify } from "util";
import { writeFile, readFile, unlink } from "fs/promises";
import * as path from "path";
import { Client, TextChannel } from "discord.js";
import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "@/modules/admin/update.view";
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
import type { PostRestartResult } from "@/modules/admin/update.view";
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
@@ -10,32 +11,79 @@ const execAsync = promisify(exec);
// Constants
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands
const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation
const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build
/**
* Execute a command with timeout protection
*/
async function execWithTimeout(
cmd: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
options: { cwd?: string } = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = exec(cmd, {
cwd: options.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_SSH_COMMAND: "ssh -o BatchMode=yes"
}
}, (error: ExecException | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
} else {
resolve({ stdout, stderr });
}
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
}, timeoutMs);
child.on("exit", () => clearTimeout(timer));
});
}
export class UpdateService {
private static readonly CONTEXT_FILE = ".restart_context.json";
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
// Cache for rollback state (set when we save, cleared on cleanup)
private static rollbackPointExists: boolean | null = null;
/**
* Check for available updates with detailed commit information
* Optimized: Parallel git commands and combined requirements check
*/
static async checkForUpdates(): Promise<UpdateInfo> {
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
const cwd = this.getDeployDir();
// Get branch first (needed for subsequent commands)
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD", DEFAULT_TIMEOUT_MS, { cwd });
const branch = branchName.trim();
const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD");
// Parallel execution: get current commit while fetching
const [currentResult] = await Promise.all([
execWithTimeout("git rev-parse --short HEAD", DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git fetch origin ${branch} --prune`, DEFAULT_TIMEOUT_MS, { cwd }) // Only fetch current branch
]);
const currentCommit = currentResult.stdout.trim();
await execAsync("git fetch --all");
// After fetch completes, get remote info in parallel
const [latestResult, logResult, diffResult] = await Promise.all([
execWithTimeout(`git rev-parse --short origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`, DEFAULT_TIMEOUT_MS, { cwd }),
execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd })
]);
const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`);
const latestCommit = latestResult.stdout.trim();
// Get commit log with author info
const { stdout: logOutput } = await execAsync(
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
);
const commits: CommitInfo[] = logOutput
// Parse commit log
const commits: CommitInfo[] = logResult.stdout
.trim()
.split("\n")
.filter(line => line.length > 0)
@@ -44,47 +92,71 @@ export class UpdateService {
return { hash: hash || "", message: message || "", author: author || "" };
});
// Parse changed files and analyze requirements in one pass
const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0);
const requirements = this.analyzeChangedFiles(changedFiles);
return {
hasUpdates: commits.length > 0,
branch,
currentCommit: currentCommit.trim(),
latestCommit: latestCommit.trim(),
currentCommit,
latestCommit,
commitCount: commits.length,
commits
commits,
requirements
};
}
/**
* Analyze what the update requires
* Analyze changed files to determine update requirements
* Extracted for reuse and clarity
*/
private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult {
const needsRootInstall = changedFiles.some(file =>
file === "package.json" || file === "bun.lock"
);
const needsWebInstall = changedFiles.some(file =>
file === "web/package.json" || file === "web/bun.lock"
);
// Only rebuild web if essential source files changed
const needsWebBuild = changedFiles.some(file =>
file.match(/^web\/src\/(components|pages|lib|index)/) ||
file === "web/build.ts" ||
file === "web/tailwind.config.ts" ||
file === "web/tsconfig.json"
);
const needsMigrations = changedFiles.some(file =>
file.includes("schema.ts") || file.startsWith("drizzle/")
);
return {
needsRootInstall,
needsWebInstall,
needsWebBuild,
needsMigrations,
changedFiles
};
}
/**
* @deprecated Use checkForUpdates() which now includes requirements
* Kept for backwards compatibility
*/
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
const cwd = this.getDeployDir();
try {
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd });
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
const needsRootInstall = changedFiles.some(file =>
file === "package.json" || file === "bun.lock"
);
const needsWebInstall = changedFiles.some(file =>
file === "web/package.json" || file === "web/bun.lock"
);
const needsMigrations = changedFiles.some(file =>
file.includes("schema.ts") || file.startsWith("drizzle/")
);
return {
needsRootInstall,
needsWebInstall,
needsMigrations,
changedFiles
};
return this.analyzeChangedFiles(changedFiles);
} catch (e) {
console.error("Failed to check update requirements:", e);
return {
needsRootInstall: false,
needsWebInstall: false,
needsWebBuild: false,
needsMigrations: false,
changedFiles: [],
error: e instanceof Error ? e : new Error(String(e))
@@ -119,9 +191,11 @@ export class UpdateService {
* Save the current commit for potential rollback
*/
static async saveRollbackPoint(): Promise<string> {
const { stdout } = await execAsync("git rev-parse HEAD");
const cwd = this.getDeployDir();
const { stdout } = await execWithTimeout("git rev-parse HEAD", DEFAULT_TIMEOUT_MS, { cwd });
const commit = stdout.trim();
await writeFile(this.ROLLBACK_FILE, commit);
this.rollbackPointExists = true; // Cache the state
return commit;
}
@@ -129,10 +203,12 @@ export class UpdateService {
* Rollback to the previous commit
*/
static async rollback(): Promise<{ success: boolean; message: string }> {
const cwd = this.getDeployDir();
try {
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
await execAsync(`git reset --hard ${rollbackCommit.trim()}`);
await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`, DEFAULT_TIMEOUT_MS, { cwd });
await unlink(this.ROLLBACK_FILE);
this.rollbackPointExists = false;
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
} catch (e) {
return {
@@ -144,12 +220,18 @@ export class UpdateService {
/**
* Check if a rollback point exists
* Uses cache when available to avoid file system access
*/
static async hasRollbackPoint(): Promise<boolean> {
if (this.rollbackPointExists !== null) {
return this.rollbackPointExists;
}
try {
await readFile(this.ROLLBACK_FILE, "utf-8");
this.rollbackPointExists = true;
return true;
} catch {
this.rollbackPointExists = false;
return false;
}
}
@@ -158,42 +240,65 @@ export class UpdateService {
* Perform the git update
*/
static async performUpdate(branch: string): Promise<void> {
await execAsync(`git reset --hard origin/${branch}`);
const cwd = this.getDeployDir();
await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd });
}
/**
* Install dependencies for specified projects
* Optimized: Parallel installation
*/
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
const outputs: string[] = [];
const tasks: Promise<{ label: string; output: string }>[] = [];
// Install dependencies in the App directory (not deploy dir) because we are updating the RUNNING app
// NOTE: If we hot-reload, we want to install in the current directory.
// If we restart, dependencies should be in the image.
// Assuming this method is used for hot-patching/minor updates without container rebuild.
if (options.root) {
const { stdout } = await execAsync("bun install");
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
tasks.push(
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" }))
);
}
if (options.web) {
const { stdout } = await execAsync("cd web && bun install");
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
tasks.push(
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" }))
);
}
return outputs.join("\n");
const results = await Promise.all(tasks);
return results.map(r => `${r.label}: ${r.output}`).join("\n");
}
/**
* Prepare restart context with rollback info
*/
static async prepareRestartContext(context: RestartContext): Promise<void> {
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await writeFile(filePath, JSON.stringify(context));
}
/**
* Trigger a restart
*
* In production Docker (with restart: unless-stopped), exiting the process
* will cause Docker to restart the container. For a full rebuild with code changes,
* use the deploy.sh script or GitHub Actions CI/CD instead of this command.
*
* Note: The /update command works for hot-reloading in development and minor
* restarts in production, but for production deployments with new code,
* use: `cd ~/Aurora && git pull && docker compose -f docker-compose.prod.yml up -d --build`
*/
static async triggerRestart(): Promise<void> {
if (process.env.RESTART_COMMAND) {
// Custom restart command from environment
exec(process.env.RESTART_COMMAND).unref();
} else {
// Exit process - Docker will restart container, dev mode will hot-reload
setTimeout(() => process.exit(0), 100);
}
}
@@ -218,7 +323,7 @@ export class UpdateService {
}
const result = await this.executePostRestartTasks(context, channel);
await this.notifyPostRestartResult(channel, result, context);
await this.notifyPostRestartResult(channel, result);
await this.cleanupContext();
} catch (e) {
console.error("Failed to handle post-restart context:", e);
@@ -229,7 +334,8 @@ export class UpdateService {
private static async loadRestartContext(): Promise<RestartContext | null> {
try {
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
const contextData = await readFile(filePath, "utf-8");
return JSON.parse(contextData) as RestartContext;
} catch {
return null;
@@ -259,60 +365,237 @@ export class UpdateService {
const result: PostRestartResult = {
installSuccess: true,
installOutput: "",
webBuildSuccess: true,
webBuildOutput: "",
migrationSuccess: true,
migrationOutput: "",
ranInstall: context.installDependencies,
ranWebBuild: context.buildWebAssets,
ranMigrations: context.runMigrations,
previousCommit: context.previousCommit,
newCommit: context.newCommit
};
// 1. Install Dependencies if needed
// Track progress for consolidated message
const progress: PostRestartProgress = {
installDeps: context.installDependencies,
buildWeb: context.buildWebAssets,
runMigrations: context.runMigrations,
currentStep: "starting"
};
// Only send progress message if there are tasks to run
const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations;
let progressMessage = hasTasks
? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] })
: null;
// Helper to update progress message
const updateProgress = async () => {
if (progressMessage) {
await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] });
}
};
// 1. Install Dependencies if needed (PARALLELIZED)
if (context.installDependencies) {
try {
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
progress.currentStep = "install";
await updateProgress();
const { stdout: rootOutput } = await execAsync("bun install");
const { stdout: webOutput } = await execAsync("cd web && bun install");
// Parallel installation of root and web dependencies
const [rootResult, webResult] = await Promise.all([
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })),
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) }))
]);
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
result.installSuccess = rootResult.success && webResult.success;
result.installOutput = `📦 Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`;
progress.installDone = true;
if (!result.installSuccess) {
console.error("Dependency Install Failed:", result.installOutput);
}
} catch (err: unknown) {
result.installSuccess = false;
result.installOutput = err instanceof Error ? err.message : String(err);
progress.installDone = true;
console.error("Dependency Install Failed:", err);
}
}
// 2. Run Migrations
// 2. Build Web Assets if needed
if (context.buildWebAssets) {
try {
progress.currentStep = "build";
await updateProgress();
const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS);
result.webBuildOutput = stdout.trim() || "Build completed successfully";
progress.buildDone = true;
} catch (err: unknown) {
result.webBuildSuccess = false;
result.webBuildOutput = err instanceof Error ? err.message : String(err);
progress.buildDone = true;
console.error("Web Build Failed:", err);
}
}
// 3. Run Migrations
if (context.runMigrations) {
try {
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
const { stdout } = await execAsync("bun x drizzle-kit migrate");
progress.currentStep = "migrate";
await updateProgress();
const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS);
result.migrationOutput = stdout;
progress.migrateDone = true;
} catch (err: unknown) {
result.migrationSuccess = false;
result.migrationOutput = err instanceof Error ? err.message : String(err);
progress.migrateDone = true;
console.error("Migration Failed:", err);
}
}
// Delete progress message before final result
if (progressMessage) {
try {
await progressMessage.delete();
} catch {
// Message may already be deleted, ignore
}
}
return result;
}
private static async notifyPostRestartResult(
channel: TextChannel,
result: PostRestartResult,
context: RestartContext
result: PostRestartResult
): Promise<void> {
// Use cached rollback state - we just saved it before restart
const hasRollback = await this.hasRollbackPoint();
await channel.send(getPostRestartEmbed(result, hasRollback));
}
private static async cleanupContext(): Promise<void> {
try {
await unlink(this.CONTEXT_FILE);
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await unlink(filePath);
} catch {
// File may not exist, ignore
}
// Don't clear rollback cache here - rollback file persists
}
// =========================================================================
// Bot-Triggered Deployment Methods
// =========================================================================
private static readonly DEPLOY_TIMEOUT_MS = 300_000; // 5 minutes for full deploy
/**
* Get the deploy directory path from environment or default
*/
static getDeployDir(): string {
return process.env.DEPLOY_DIR || "/app/deploy";
}
/**
* Check if deployment is available (docker socket accessible)
*/
static async isDeployAvailable(): Promise<boolean> {
try {
await execWithTimeout("docker --version", 5000);
return true;
} catch {
return false;
}
}
/**
* Perform a full deployment: git pull + docker compose rebuild
* This will restart the container with the new code
*
* @param onProgress - Callback for progress updates
* @returns Object with success status, output, and commit info
*/
static async performDeploy(onProgress?: (step: string) => void): Promise<{
success: boolean;
previousCommit: string;
newCommit: string;
output: string;
error?: string;
}> {
const deployDir = this.getDeployDir();
let previousCommit = "";
let newCommit = "";
let output = "";
try {
// 1. Get current commit
onProgress?.("Getting current version...");
const { stdout: currentSha } = await execWithTimeout(
`cd ${deployDir} && git rev-parse --short HEAD`,
DEFAULT_TIMEOUT_MS
);
previousCommit = currentSha.trim();
output += `📍 Current: ${previousCommit}\n`;
// 2. Pull latest changes
onProgress?.("Pulling latest code...");
const { stdout: pullOutput } = await execWithTimeout(
`cd ${deployDir} && git pull origin main`,
DEFAULT_TIMEOUT_MS
);
output += `📥 Pull: ${pullOutput.includes("Already up to date") ? "Already up to date" : "Updated"}\n`;
// 3. Get new commit
const { stdout: newSha } = await execWithTimeout(
`cd ${deployDir} && git rev-parse --short HEAD`,
DEFAULT_TIMEOUT_MS
);
newCommit = newSha.trim();
output += `📍 New: ${newCommit}\n`;
// 4. Rebuild and restart container (this will kill the current process)
onProgress?.("Rebuilding and restarting...");
// Use spawn with detached mode so the command continues after we exit
const { spawn } = await import("child_process");
const deployProcess = spawn(
"sh",
["-c", `cd ${deployDir} && docker compose -f docker-compose.prod.yml up -d --build`],
{
detached: true,
stdio: "ignore"
}
);
deployProcess.unref();
output += `🚀 Deploy triggered - container will restart\n`;
console.log("Deploy triggered successfully:", output);
return {
success: true,
previousCommit,
newCommit,
output
};
} catch (error) {
return {
success: false,
previousCommit,
newCommit,
output,
error: error instanceof Error ? error.message : String(error)
};
}
}
}

View File

@@ -0,0 +1,99 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
// Mock DrizzleClient before importing service
const mockFindMany = mock();
const mockLimit = mock();
// Helper to support the chained calls in getLeaderboards
const mockChain = {
from: () => mockChain,
orderBy: () => mockChain,
limit: mockLimit
};
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
select: () => mockChain,
query: {
lootdrops: {
findMany: mockFindMany
}
}
}
}));
// Import service after mocking
import { dashboardService } from "./dashboard.service";
describe("dashboardService", () => {
beforeEach(() => {
mockFindMany.mockClear();
mockLimit.mockClear();
});
describe("getActiveLootdrops", () => {
test("should return active lootdrops when found", async () => {
const mockDrops = [
{
messageId: "123",
channelId: "general",
rewardAmount: 100,
currency: "Gold",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
claimedBy: null
}
];
mockFindMany.mockResolvedValue(mockDrops);
const result = await dashboardService.getActiveLootdrops();
expect(result).toEqual(mockDrops);
expect(mockFindMany).toHaveBeenCalledTimes(1);
});
test("should return empty array if no active drops", async () => {
mockFindMany.mockResolvedValue([]);
const result = await dashboardService.getActiveLootdrops();
expect(result).toEqual([]);
});
});
describe("getLeaderboards", () => {
test("should combine top levels and wealth", async () => {
const mockTopLevels = [
{ username: "Alice", level: 10, avatar: "a.png" },
{ username: "Bob", level: 5, avatar: null },
{ username: "Charlie", level: 2, avatar: "c.png" }
];
const mockTopWealth = [
{ username: "Alice", balance: 1000n, avatar: "a.png" },
{ username: "Dave", balance: 500n, avatar: "d.png" },
{ username: "Bob", balance: 100n, avatar: null }
];
// Mock sequential calls to limit()
// First call is topLevels, second is topWealth
mockLimit
.mockResolvedValueOnce(mockTopLevels)
.mockResolvedValueOnce(mockTopWealth);
const result = await dashboardService.getLeaderboards();
expect(result.topLevels).toEqual(mockTopLevels);
// Verify balance BigInt to string conversion
expect(result.topWealth).toHaveLength(3);
expect(result.topWealth[0]!.balance).toBe("1000");
expect(result.topWealth[0]!.username).toBe("Alice");
expect(result.topWealth[1]!.balance).toBe("500");
expect(mockLimit).toHaveBeenCalledTimes(2);
});
test("should handle empty leaderboards", async () => {
mockLimit.mockResolvedValue([]);
const result = await dashboardService.getLeaderboards();
expect(result.topLevels).toEqual([]);
expect(result.topWealth).toEqual([]);
});
});
});

View File

@@ -0,0 +1,284 @@
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, moderationCases, inventory, lootdrops, items, type User } from "@db/schema";
import { desc, sql, gte, eq } from "drizzle-orm";
import type { RecentEvent, ActivityData } from "./dashboard.types";
import { TransactionType } from "@shared/lib/constants";
export const dashboardService = {
/**
* Get count of active users from database
*/
getActiveUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users)
.where(sql`${users.isActive} = true`);
return Number(result[0]?.count || 0);
},
/**
* Get total user count
*/
getTotalUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users);
return Number(result[0]?.count || 0);
},
/**
* Get economy statistics
*/
getEconomyStats: async (): Promise<{
totalWealth: bigint;
avgLevel: number;
topStreak: number;
}> => {
const allUsers = await DrizzleClient.select().from(users);
const totalWealth = allUsers.reduce(
(acc: bigint, u: User) => acc + (u.balance || 0n),
0n
);
const avgLevel = allUsers.length > 0
? Math.round(
allUsers.reduce((acc: number, u: User) => acc + (u.level || 1), 0) / allUsers.length
)
: 1;
const topStreak = allUsers.reduce(
(max: number, u: User) => Math.max(max, u.dailyStreak || 0),
0
);
return { totalWealth, avgLevel, topStreak };
},
/**
* Get total items in circulation
*/
getTotalItems: async (): Promise<number> => {
const result = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
return Number(result[0]?.total || 0);
},
/**
* Get recent transactions as events (last 24 hours)
*/
getRecentTransactions: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentTx = await DrizzleClient.query.transactions.findMany({
limit,
orderBy: [desc(transactions.createdAt)],
where: gte(transactions.createdAt, oneDayAgo),
with: {
user: true,
},
});
return recentTx.map((tx) => ({
type: 'info' as const,
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
timestamp: tx.createdAt || new Date(),
icon: getTransactionIcon(tx.type),
}));
},
/**
* Get recent moderation cases as events (last 24 hours)
*/
getRecentModerationCases: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentCases = await DrizzleClient.query.moderationCases.findMany({
limit,
orderBy: [desc(moderationCases.createdAt)],
where: gte(moderationCases.createdAt, oneDayAgo),
});
return recentCases.map((modCase) => ({
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
timestamp: modCase.createdAt || new Date(),
icon: getModerationIcon(modCase.type as string),
}));
},
/**
* Get combined recent events (transactions + moderation)
*/
getRecentEvents: async (limit: number = 10): Promise<RecentEvent[]> => {
const [txEvents, modEvents] = await Promise.all([
dashboardService.getRecentTransactions(limit),
dashboardService.getRecentModerationCases(limit),
]);
// Combine and sort by timestamp
const allEvents = [...txEvents, ...modEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, limit);
return allEvents;
},
/**
* Records a new internal event and broadcasts it via WebSocket
*/
recordEvent: async (event: Omit<RecentEvent, 'timestamp'>): Promise<void> => {
const fullEvent: RecentEvent = {
...event,
timestamp: new Date(),
};
// Broadcast to WebSocket clients
try {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.DASHBOARD.NEW_EVENT, {
...fullEvent,
timestamp: (fullEvent.timestamp instanceof Date)
? fullEvent.timestamp.toISOString()
: fullEvent.timestamp
});
} catch (e) {
console.error("Failed to emit system event:", e);
}
},
/**
* Get hourly activity aggregation for the last 24 hours
*/
getActivityAggregation: async (): Promise<ActivityData[]> => {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Postgres aggregation
// We treat everything as a transaction.
// We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers)
const result = await DrizzleClient
.select({
hour: sql<string>`date_trunc('hour', ${transactions.createdAt})`,
transactions: sql<string>`COUNT(*)`,
commands: sql<string>`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})`
})
.from(transactions)
.where(gte(transactions.createdAt, twentyFourHoursAgo))
.groupBy(sql`1`)
.orderBy(sql`1`);
// Map into a record for easy lookups
const dataMap = new Map<string, { commands: number, transactions: number }>();
result.forEach(row => {
if (!row.hour) return;
const dateStr = new Date(row.hour).toISOString();
dataMap.set(dateStr, {
commands: Number(row.commands),
transactions: Number(row.transactions)
});
});
// Generate the last 24 hours of data
const activity: ActivityData[] = [];
const current = new Date();
current.setHours(current.getHours(), 0, 0, 0);
for (let i = 23; i >= 0; i--) {
const h = new Date(current.getTime() - i * 60 * 60 * 1000);
const iso = h.toISOString();
const existing = dataMap.get(iso);
activity.push({
hour: iso,
commands: existing?.commands || 0,
transactions: existing?.transactions || 0
});
}
return activity;
},
/**
* Get active lootdrops
*/
getActiveLootdrops: async () => {
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
return activeDrops;
},
/**
* Get leaderboards (Top 3 Levels and Wealth)
*/
getLeaderboards: async () => {
const topLevels = await DrizzleClient.select({
username: users.username,
level: users.level,
})
.from(users)
.orderBy(desc(users.level))
.limit(10);
const topWealth = await DrizzleClient.select({
username: users.username,
balance: users.balance,
})
.from(users)
.orderBy(desc(users.balance))
.limit(10);
const topNetWorth = await DrizzleClient.select({
username: users.username,
netWorth: sql<bigint>`${users.balance} + COALESCE(SUM(${items.price} * ${inventory.quantity}), 0)`.as('net_worth')
})
.from(users)
.leftJoin(inventory, eq(users.id, inventory.userId))
.leftJoin(items, eq(inventory.itemId, items.id))
.groupBy(users.id, users.username, users.balance)
.orderBy(desc(sql`net_worth`))
.limit(10);
return {
topLevels,
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() })),
topNetWorth: topNetWorth.map(u => ({ ...u, netWorth: (u.netWorth || 0n).toString() }))
};
}
};
/**
* Helper to get icon for transaction type
*/
function getTransactionIcon(type: string): string {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
if (type.includes("TRANSFER")) return "💸";
return "💫";
}
/**
* Helper to get icon for moderation type
*/
function getModerationIcon(type: string): string {
switch (type) {
case 'warn': return '⚠️';
case 'timeout': return '⏸️';
case 'kick': return '👢';
case 'ban': return '🔨';
case 'note': return '📝';
case 'prune': return '🧹';
default: return '🛡️';
}
}

View File

@@ -0,0 +1,122 @@
import { z } from "zod";
export const RecentEventSchema = z.object({
type: z.enum(['success', 'error', 'info', 'warn']),
message: z.string(),
timestamp: z.union([z.date(), z.string().datetime()]),
icon: z.string().optional(),
});
export type RecentEvent = z.infer<typeof RecentEventSchema>;
export const DashboardStatsSchema = z.object({
bot: z.object({
name: z.string(),
avatarUrl: z.string().nullable(),
status: z.string().nullable(),
}),
guilds: z.object({
count: z.number(),
changeFromLastMonth: z.number().optional(),
}),
users: z.object({
active: z.number(),
total: z.number(),
changePercentFromLastMonth: z.number().optional(),
}),
commands: z.object({
total: z.number(),
active: z.number(),
disabled: z.number(),
changePercentFromLastMonth: z.number().optional(),
}),
ping: z.object({
avg: z.number(),
changeFromLastHour: z.number().optional(),
}),
economy: z.object({
totalWealth: z.string(),
avgLevel: z.number(),
topStreak: z.number(),
totalItems: z.number().optional(),
}),
recentEvents: z.array(RecentEventSchema),
activeLootdrops: z.array(z.object({
rewardAmount: z.number(),
currency: z.string(),
createdAt: z.string(),
expiresAt: z.string().nullable(),
})).optional(),
lootdropState: z.object({
monitoredChannels: z.number(),
hottestChannel: z.object({
id: z.string(),
messages: z.number(),
progress: z.number(),
cooldown: z.boolean(),
}).nullable(),
config: z.object({
requiredMessages: z.number(),
dropChance: z.number(),
}),
}).optional(),
leaderboards: z.object({
topLevels: z.array(z.object({
username: z.string(),
level: z.number(),
})),
topWealth: z.array(z.object({
username: z.string(),
balance: z.string(),
})),
topNetWorth: z.array(z.object({
username: z.string(),
netWorth: z.string(),
})),
}).optional(),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
maintenanceMode: z.boolean(),
});
export type DashboardStats = z.infer<typeof DashboardStatsSchema>;
export const ClientStatsSchema = z.object({
bot: z.object({
name: z.string(),
avatarUrl: z.string().nullable(),
status: z.string().nullable(),
}),
guilds: z.number(),
ping: z.number(),
cachedUsers: z.number(),
commandsRegistered: z.number(),
commandsKnown: z.number(),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
});
export type ClientStats = z.infer<typeof ClientStatsSchema>;
// Action Schemas
export const MaintenanceModeSchema = z.object({
enabled: z.boolean(),
reason: z.string().optional(),
});
// WebSocket Message Schemas
export const WsMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("PING") }),
z.object({ type: z.literal("PONG") }),
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
]);
export type WsMessage = z.infer<typeof WsMessageSchema>;
export const ActivityDataSchema = z.object({
hour: z.string(),
commands: z.number(),
transactions: z.number(),
});
export type ActivityData = z.infer<typeof ActivityDataSchema>;

View File

@@ -61,6 +61,14 @@ export const economyService = {
description: `Transfer from ${fromUserId}`,
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'info',
message: `${sender.username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`,
icon: '💸'
});
return { success: true, amount };
}, tx);
},
@@ -79,7 +87,7 @@ export const economyService = {
});
if (cooldown && cooldown.expiresAt > now) {
throw new UserError(`Daily already claimed today. Next claim <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F>`);
throw new UserError(`You have already claimed your daily reward today.\nNext claim available: <t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:F> (<t:${Math.floor(cooldown.expiresAt.getTime() / 1000)}:R>)`);
}
// Get user for streak logic
@@ -149,6 +157,14 @@ export const economyService = {
description: `Daily reward (Streak: ${streak})`,
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user.username} claimed daily reward: ${totalReward.toLocaleString()} AU`,
icon: '☀️'
});
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
}, tx);
},
@@ -180,6 +196,10 @@ export const economyService = {
description: description,
});
// Trigger Quest Event
const { questService } = await import("@shared/modules/quest/quest.service");
await questService.handleEvent(id, type, 1, txFn);
return user;
}, tx);
},

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test";
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
import { users, userTimers, transactions } from "@db/schema";
// Define mock functions
const mockFindFirst = mock();
const mockInsert = mock();
const mockUpdate = mock();
const mockValues = mock();
const mockReturning = mock();
const mockSet = mock();
const mockWhere = mock();
// Chainable mock setup
mockInsert.mockReturnValue({ values: mockValues });
mockValues.mockReturnValue({ returning: mockReturning });
mockUpdate.mockReturnValue({ set: mockSet });
mockSet.mockReturnValue({ where: mockWhere });
mockWhere.mockReturnValue({ returning: mockReturning });
// Mock DrizzleClient
mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
});
return {
DrizzleClient: {
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
transaction: async (cb: any) => {
return cb(createMockTx());
}
},
};
});
// Mock withTransaction
mock.module("@/lib/db", () => ({
withTransaction: async (cb: any, tx?: any) => {
if (tx) return cb(tx);
return cb({
query: {
users: { findFirst: mockFindFirst },
userTimers: { findFirst: mockFindFirst },
},
insert: mockInsert,
update: mockUpdate,
});
}
}));
// Mock Config
mock.module("@shared/lib/config", () => ({
config: {
economy: {
exam: {
multMin: 1.0,
multMax: 2.0,
}
}
}
}));
// Mock User Service
mock.module("@shared/modules/user/user.service", () => ({
userService: {
getOrCreateUser: mock()
}
}));
// Mock Dashboard Service
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock()
}
}));
describe("ExamService", () => {
beforeEach(() => {
mockFindFirst.mockReset();
mockInsert.mockClear();
mockUpdate.mockClear();
mockValues.mockClear();
mockReturning.mockClear();
mockSet.mockClear();
mockWhere.mockClear();
});
describe("getExamStatus", () => {
it("should return NOT_REGISTERED if no timer exists", async () => {
mockFindFirst.mockResolvedValue(undefined);
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.NOT_REGISTERED);
});
it("should return COOLDOWN if now < expiresAt", async () => {
const now = new Date("2024-01-10T12:00:00Z");
setSystemTime(now);
const future = new Date("2024-01-11T00:00:00Z");
mockFindFirst.mockResolvedValue({
expiresAt: future,
metadata: { examDay: 3, lastXp: "100" }
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.COOLDOWN);
expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0));
});
it("should return MISSED if it is the wrong day", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week
mockFindFirst.mockResolvedValue({
expiresAt: past,
metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.MISSED);
expect(status.examDay).toBe(3);
});
it("should return AVAILABLE if it is the correct day", async () => {
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValue({
expiresAt: past,
metadata: { examDay: 3, lastXp: "100" }
});
const status = await examService.getExamStatus("1");
expect(status.status).toBe(ExamStatus.AVAILABLE);
expect(status.examDay).toBe(3);
expect(status.lastXp).toBe(100n);
});
});
describe("registerForExam", () => {
it("should create user and timer correctly", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const { userService } = await import("@shared/modules/user/user.service");
(userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n });
const result = await examService.registerForExam("1", "testuser");
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
expect(result.examDay).toBe(1);
expect(mockInsert).toHaveBeenCalledWith(userTimers);
expect(mockInsert).toHaveBeenCalledTimes(1);
});
});
describe("takeExam", () => {
it("should return NOT_REGISTERED if not registered", async () => {
mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check
.mockResolvedValueOnce(undefined); // timer check
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.NOT_REGISTERED);
});
it("should handle missed exam and schedule for next exam day", async () => {
const now = new Date("2024-01-15T12:00:00Z"); // Monday (1)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user
.mockResolvedValueOnce({
expiresAt: past,
metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday
}); // timer
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.MISSED);
expect(result.examDay).toBe(3);
// Should set next exam to next Wednesday
// Monday (1) + 2 days = Wednesday (3)
const expected = new Date("2024-01-17T00:00:00Z");
expect(result.nextExamAt!.getTime()).toBe(expected.getTime());
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
});
it("should calculate rewards and update state when passed", async () => {
const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3)
setSystemTime(now);
const past = new Date("2024-01-10T00:00:00Z");
mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user
.mockResolvedValueOnce({
expiresAt: past,
metadata: { examDay: 3, lastXp: "500" }
}); // timer
const result = await examService.takeExam("1");
expect(result.status).toBe(ExamStatus.AVAILABLE);
expect(result.xpDiff).toBe(500n);
// Multiplier is between 1.0 and 2.0 based on mock config
expect(result.multiplier).toBeGreaterThanOrEqual(1.0);
expect(result.multiplier).toBeLessThanOrEqual(2.0);
expect(result.reward).toBeGreaterThanOrEqual(500n);
expect(result.reward).toBeLessThanOrEqual(1000n);
expect(mockUpdate).toHaveBeenCalledWith(userTimers);
expect(mockUpdate).toHaveBeenCalledWith(users);
// Verify transaction
expect(mockInsert).toHaveBeenCalledWith(transactions);
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
amount: result.reward,
userId: 1n,
type: expect.anything()
}));
});
});
});

View File

@@ -0,0 +1,262 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
const EXAM_TIMER_KEY = 'default';
export interface ExamMetadata {
examDay: number;
lastXp: string;
}
export enum ExamStatus {
NOT_REGISTERED = 'NOT_REGISTERED',
COOLDOWN = 'COOLDOWN',
MISSED = 'MISSED',
AVAILABLE = 'AVAILABLE',
}
export interface ExamActionResult {
status: ExamStatus;
nextExamAt?: Date;
reward?: bigint;
xpDiff?: bigint;
multiplier?: number;
examDay?: number;
}
export const examService = {
/**
* Get the current exam status for a user.
*/
async getExamStatus(userId: string, tx?: Transaction) {
return await withTransaction(async (txFn) => {
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const currentDay = now.getDay();
if (currentDay !== metadata.examDay) {
return {
status: ExamStatus.MISSED,
nextExamAt: expiresAt,
examDay: metadata.examDay
};
}
return {
status: ExamStatus.AVAILABLE,
examDay: metadata.examDay,
lastXp: BigInt(metadata.lastXp || "0")
};
}, tx);
},
/**
* Register a user for the first time.
*/
async registerForExam(userId: string, username: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
// Ensure user exists
const { userService } = await import("@shared/modules/user/user.service");
const user = await userService.getOrCreateUser(userId, username, txFn);
if (!user) throw new Error("Failed to get or create user.");
const now = new Date();
const currentDay = now.getDay();
// Set next exam to next week
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const metadata: ExamMetadata = {
examDay: currentDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.insert(userTimers).values({
userId: BigInt(userId),
type: EXAM_TIMER_TYPE,
key: EXAM_TIMER_KEY,
expiresAt: nextExamDate,
metadata: metadata
});
return {
status: ExamStatus.NOT_REGISTERED,
nextExamAt: nextExamDate,
examDay: currentDay
};
}, tx);
},
/**
* Take the exam. Handles missed exams and reward calculations.
*/
async takeExam(userId: string, tx?: Transaction): Promise<ExamActionResult> {
return await withTransaction(async (txFn) => {
const user = await txFn.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
if (!user) throw new Error("User not found");
const timer = await txFn.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
)
});
if (!timer) {
return { status: ExamStatus.NOT_REGISTERED };
}
const now = new Date();
const expiresAt = new Date(timer.expiresAt);
expiresAt.setHours(0, 0, 0, 0);
if (now < expiresAt) {
return {
status: ExamStatus.COOLDOWN,
nextExamAt: expiresAt
};
}
const metadata = timer.metadata as unknown as ExamMetadata;
const examDay = metadata.examDay;
const currentDay = now.getDay();
if (currentDay !== examDay) {
// Missed exam logic
let daysUntil = (examDay - currentDay + 7) % 7;
if (daysUntil === 0) daysUntil = 7;
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + daysUntil);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: (user.xp ?? 0n).toString()
};
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
return {
status: ExamStatus.MISSED,
nextExamAt: nextExamDate,
examDay: examDay
};
}
// Reward Calculation
const lastXp = BigInt(metadata.lastXp || "0");
const currentXp = user.xp ?? 0n;
const diff = currentXp - lastXp;
const multMin = config.economy.exam.multMin;
const multMax = config.economy.exam.multMax;
const multiplier = Math.random() * (multMax - multMin) + multMin;
let reward = 0n;
if (diff > 0n) {
// Use scaled BigInt arithmetic to avoid precision loss with large XP values
const scaledMultiplier = BigInt(Math.round(multiplier * 10000));
reward = (diff * scaledMultiplier) / 10000n;
}
const nextExamDate = new Date(now);
nextExamDate.setDate(now.getDate() + 7);
nextExamDate.setHours(0, 0, 0, 0);
const newMetadata: ExamMetadata = {
examDay: examDay,
lastXp: currentXp.toString()
};
// Update Timer
await txFn.update(userTimers)
.set({
expiresAt: nextExamDate,
metadata: newMetadata
})
.where(and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, EXAM_TIMER_TYPE),
eq(userTimers.key, EXAM_TIMER_KEY)
));
// Add Currency
if (reward > 0n) {
await txFn.update(users)
.set({
balance: sql`${users.balance} + ${reward}`
})
.where(eq(users.id, BigInt(userId)));
// Add Transaction Record
await txFn.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.EXAM_REWARD,
description: `Weekly exam reward (XP Diff: ${diff})`,
});
}
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`,
icon: '🎓'
});
return {
status: ExamStatus.AVAILABLE,
nextExamAt: nextExamDate,
reward,
xpDiff: diff,
multiplier,
examDay
};
}, tx);
}
};

View File

@@ -163,6 +163,48 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." };
}
}
public getLootdropState() {
let hottestChannel: { id: string; messages: number; progress: number; cooldown: boolean; } | null = null;
let maxMessages = -1;
const window = config.lootdrop.activityWindowMs;
const now = Date.now();
const required = config.lootdrop.minMessages;
for (const [channelId, timestamps] of this.channelActivity.entries()) {
// Filter valid just to be sure we are reporting accurate numbers
const validCount = timestamps.filter(t => now - t < window).length;
// Check cooldown
const cooldownUntil = this.channelCooldowns.get(channelId);
const isOnCooldown = !!(cooldownUntil && now < cooldownUntil);
if (validCount > maxMessages) {
maxMessages = validCount;
hottestChannel = {
id: channelId,
messages: validCount,
progress: Math.min(100, (validCount / required) * 100),
cooldown: isOnCooldown
};
}
}
return {
monitoredChannels: this.channelActivity.size,
hottestChannel,
config: {
requiredMessages: required,
dropChance: config.lootdrop.spawnChance
}
};
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
}
export const lootdropService = new LootdropService();

View File

@@ -37,6 +37,11 @@ export const inventoryService = {
eq(inventory.itemId, itemId)
))
.returning();
// Trigger Quest Event
const { questService } = await import("@shared/modules/quest/quest.service");
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
return entry;
} else {
// Check Slot Limit
@@ -60,6 +65,11 @@ export const inventoryService = {
quantity: quantity,
})
.returning();
// Trigger Quest Event
const { questService } = await import("@shared/modules/quest/quest.service");
await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn);
return entry;
}
}, tx);
@@ -179,6 +189,10 @@ export const inventoryService = {
await inventoryService.removeItem(userId, itemId, 1n, txFn);
}
// Trigger Quest Event
const { questService } = await import("@shared/modules/quest/quest.service");
await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn);
return { success: true, results, usageData, item };
}, tx);
},

View File

@@ -68,6 +68,10 @@ export const levelingService = {
.where(eq(users.id, BigInt(id)))
.returning();
// Trigger Quest Event
const { questService } = await import("@shared/modules/quest/quest.service");
await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn);
return { user: updatedUser, levelUp, currentLevel: newLevel };
}, tx);
},

View File

@@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => {
const createMockTx = () => ({
query: {
userQuests: { findFirst: mockFindFirst, findMany: mockFindMany },
quests: { findMany: mockFindMany },
},
insert: mockInsert,
update: mockUpdate,
@@ -148,4 +149,147 @@ describe("questService", () => {
expect(result).toEqual(mockData as any);
});
});
describe("getAvailableQuests", () => {
it("should return quests not yet accepted by user", async () => {
// First call to findMany (userQuests) returns accepted quest IDs
// Second call to findMany (quests) returns available quests
mockFindMany
.mockResolvedValueOnce([{ questId: 1 }]) // userQuests
.mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests
const result = await questService.getAvailableQuests("1");
expect(result).toEqual([{ id: 2, name: "New Quest" }] as any);
expect(mockFindMany).toHaveBeenCalledTimes(2);
});
it("should return all quests if user has no assigned quests", async () => {
mockFindMany
.mockResolvedValueOnce([]) // userQuests
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests
const result = await questService.getAvailableQuests("1");
expect(result).toEqual([{ id: 1 }, { id: 2 }] as any);
});
});
describe("handleEvent", () => {
it("should progress a quest with sub-events", async () => {
const mockUserQuest = {
userId: 1n,
questId: 101,
progress: 0,
completedAt: null,
quest: { triggerEvent: "ITEM_USE:101", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]);
await questService.handleEvent("1", "ITEM_USE:101", 1);
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
});
it("should complete a quest when target reached using sub-events", async () => {
const mockUserQuest = {
userId: 1n,
questId: 101,
progress: 4,
completedAt: null,
quest: {
triggerEvent: "ITEM_COLLECT:505",
requirements: { target: 5 },
rewards: { balance: 100 }
}
};
mockFindMany.mockResolvedValue([mockUserQuest]);
mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
// Verify completeQuest was called (it will update completedAt)
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) });
});
it("should progress a quest with generic events", async () => {
const mockUserQuest = {
userId: 1n,
questId: 102,
progress: 0,
completedAt: null,
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
mockReturning.mockResolvedValue([{ userId: 1n, questId: 102, progress: 1 }]);
await questService.handleEvent("1", "ITEM_COLLECT:505", 1);
expect(mockUpdate).toHaveBeenCalled();
expect(mockSet).toHaveBeenCalledWith({ progress: 1 });
});
it("should ignore events that are not prefix matches", async () => {
const mockUserQuest = {
userId: 1n,
questId: 103,
progress: 0,
completedAt: null,
quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
await questService.handleEvent("1", "ITEM_COLLECT_UNRELATED", 1);
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should not progress a specific quest with a different specific event", async () => {
const mockUserQuest = {
userId: 1n,
questId: 104,
progress: 0,
completedAt: null,
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
await questService.handleEvent("1", "ITEM_COLLECT:202", 1);
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should not progress a specific quest with a generic event", async () => {
const mockUserQuest = {
userId: 1n,
questId: 105,
progress: 0,
completedAt: null,
quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
await questService.handleEvent("1", "ITEM_COLLECT", 1);
expect(mockUpdate).not.toHaveBeenCalled();
});
it("should ignore irrelevant events", async () => {
const mockUserQuest = {
userId: 1n,
questId: 101,
progress: 0,
completedAt: null,
quest: { triggerEvent: "DIFFERENT_EVENT", requirements: { target: 5 } }
};
mockFindMany.mockResolvedValue([mockUserQuest]);
await questService.handleEvent("1", "TEST_EVENT", 1);
expect(mockUpdate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { userQuests } from "@db/schema";
import { userQuests, quests } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { UserError } from "@shared/lib/errors";
import { DrizzleClient } from "@shared/db/DrizzleClient";
@@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants";
import { systemEvents, EVENTS } from "@shared/lib/events";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
@@ -34,6 +35,40 @@ export const questService = {
}, tx);
},
handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
// 1. Fetch active user quests for this event
const activeUserQuests = await txFn.query.userQuests.findMany({
where: and(
eq(userQuests.userId, BigInt(userId)),
),
with: {
quest: true
}
});
const relevant = activeUserQuests.filter(uq => {
const trigger = uq.quest.triggerEvent;
// Exact match or prefix match (e.g. ITEM_COLLECT matches ITEM_COLLECT:101)
const isMatch = eventName === trigger || eventName.startsWith(trigger + ":");
return isMatch && !uq.completedAt;
});
for (const uq of relevant) {
const requirements = uq.quest.requirements as { target?: number };
const target = requirements?.target || 1;
const newProgress = (uq.progress || 0) + weight;
if (newProgress >= target) {
await questService.completeQuest(userId, uq.questId, txFn);
} else {
await questService.updateProgress(userId, uq.questId, newProgress, txFn);
}
}
}, tx);
},
completeQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => {
const userQuest = await txFn.query.userQuests.findFirst({
@@ -73,6 +108,14 @@ export const questService = {
results.xp = xp;
}
// Emit completion event for the bot to handle notifications
systemEvents.emit(EVENTS.QUEST.COMPLETED, {
userId,
questId,
quest: userQuest.quest,
rewards: results
});
return { success: true, rewards: results };
}, tx);
},
@@ -84,5 +127,75 @@ export const questService = {
quest: true,
}
});
},
async getAvailableQuests(userId: string) {
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
columns: {
questId: true
}
})).map(uq => uq.questId);
return await DrizzleClient.query.quests.findMany({
where: (quests, { notInArray }) => userQuestIds.length > 0
? notInArray(quests.id, userQuestIds)
: undefined
});
},
async createQuest(data: {
name: string;
description: string;
triggerEvent: string;
requirements: { target: number };
rewards: { xp: number; balance: number };
}, tx?: Transaction) {
return await withTransaction(async (txFn) => {
return await txFn.insert(quests)
.values({
name: data.name,
description: data.description,
triggerEvent: data.triggerEvent,
requirements: data.requirements,
rewards: data.rewards,
})
.returning();
}, tx);
},
async getAllQuests() {
return await DrizzleClient.query.quests.findMany({
orderBy: (quests, { asc }) => [asc(quests.id)],
});
},
async deleteQuest(id: number, tx?: Transaction) {
return await withTransaction(async (txFn) => {
return await txFn.delete(quests)
.where(eq(quests.id, id))
.returning();
}, tx);
},
async updateQuest(id: number, data: {
name?: string;
description?: string;
triggerEvent?: string;
requirements?: { target?: number };
rewards?: { xp?: number; balance?: number };
}, tx?: Transaction) {
return await withTransaction(async (txFn) => {
return await txFn.update(quests)
.set({
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.triggerEvent !== undefined && { triggerEvent: data.triggerEvent }),
...(data.requirements !== undefined && { requirements: data.requirements }),
...(data.rewards !== undefined && { rewards: data.rewards }),
})
.where(eq(quests.id, id))
.returning();
}, tx);
}
};

View File

@@ -196,5 +196,10 @@ export const tradeService = {
});
tradeService.endSession(threadId);
},
clearSessions: () => {
sessions.clear();
console.log("[TradeService] All active trade sessions cleared.");
}
};

View File

@@ -0,0 +1,241 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
import { triviaService } from "./trivia.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, userTimers } from "@db/schema";
import { eq, and } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { TimerType } from "@shared/lib/constants";
// Mock fetch for OpenTDB API
const mockFetch = mock(() => Promise.resolve({
json: () => Promise.resolve({
response_code: 0,
results: [{
category: Buffer.from('General Knowledge').toString('base64'),
type: 'multiple',
difficulty: Buffer.from('medium').toString('base64'),
question: Buffer.from('What is 2 + 2?').toString('base64'),
correct_answer: Buffer.from('4').toString('base64'),
incorrect_answers: [
Buffer.from('3').toString('base64'),
Buffer.from('5').toString('base64'),
Buffer.from('22').toString('base64'),
]
}]
})
}));
global.fetch = mockFetch as any;
describe("TriviaService", () => {
const TEST_USER_ID = "999999999";
const TEST_USERNAME = "testuser";
beforeEach(async () => {
// Clean up test data
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
// Ensure test user exists with sufficient balance
await DrizzleClient.insert(users)
.values({
id: BigInt(TEST_USER_ID),
username: TEST_USERNAME,
balance: 1000n,
xp: 0n,
})
.onConflictDoUpdate({
target: [users.id],
set: {
balance: 1000n,
}
});
});
afterEach(async () => {
// Clean up
await DrizzleClient.delete(userTimers)
.where(eq(userTimers.userId, BigInt(TEST_USER_ID)));
});
describe("fetchQuestion", () => {
it("should fetch and decode a trivia question", async () => {
const question = await triviaService.fetchQuestion();
expect(question).toBeDefined();
expect(question.question).toBe('What is 2 + 2?');
expect(question.correctAnswer).toBe('4');
expect(question.incorrectAnswers).toHaveLength(3);
expect(question.type).toBe('multiple');
});
});
describe("canPlayTrivia", () => {
it("should allow playing when no cooldown exists", async () => {
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
expect(result.nextAvailable).toBeUndefined();
});
it("should prevent playing when on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(false);
expect(result.nextAvailable).toBeDefined();
});
it("should allow playing when cooldown has expired", async () => {
const pastDate = new Date(Date.now() - 1000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: pastDate,
});
const result = await triviaService.canPlayTrivia(TEST_USER_ID);
expect(result.canPlay).toBe(true);
});
});
describe("startTrivia", () => {
it("should start a trivia session and deduct entry fee", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
expect(session).toBeDefined();
expect(session.sessionId).toContain(TEST_USER_ID);
expect(session.userId).toBe(TEST_USER_ID);
expect(session.question).toBeDefined();
expect(session.allAnswers).toHaveLength(4);
expect(session.entryFee).toBe(config.trivia.entryFee);
expect(session.potentialReward).toBeGreaterThan(0n);
// Verify balance deduction
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(1000n - config.trivia.entryFee);
// Verify cooldown was set
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(TEST_USER_ID)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
)
});
expect(cooldown).toBeDefined();
});
it("should throw error if user has insufficient balance", async () => {
// Set balance to less than entry fee
await DrizzleClient.update(users)
.set({ balance: 10n })
.where(eq(users.id, BigInt(TEST_USER_ID)));
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('Insufficient funds');
});
it("should throw error if user is on cooldown", async () => {
const futureDate = new Date(Date.now() + 60000);
await DrizzleClient.insert(userTimers).values({
userId: BigInt(TEST_USER_ID),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: futureDate,
});
await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME))
.rejects.toThrow('cooldown');
});
});
describe("submitAnswer", () => {
it("should award prize for correct answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
expect(result.correct).toBe(true);
expect(result.reward).toBe(session.potentialReward);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance increase
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore + session.potentialReward);
});
it("should not award prize for incorrect answer", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const balanceBefore = (await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
}))!.balance!;
const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, false);
expect(result.correct).toBe(false);
expect(result.reward).toBe(0n);
expect(result.correctAnswer).toBe(session.question.correctAnswer);
// Verify balance unchanged (already deducted at start)
const user = await DrizzleClient.query.users.findFirst({
where: eq(users.id, BigInt(TEST_USER_ID))
});
expect(user?.balance).toBe(balanceBefore);
});
it("should throw error if session doesn't exist", async () => {
await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
it("should prevent double submission", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true);
// Try to submit again
await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true))
.rejects.toThrow('Session not found');
});
});
describe("getSession", () => {
it("should retrieve active session", async () => {
const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME);
const retrieved = triviaService.getSession(session.sessionId);
expect(retrieved).toBeDefined();
expect(retrieved?.sessionId).toBe(session.sessionId);
});
it("should return undefined for non-existent session", () => {
const retrieved = triviaService.getSession("invalid_session");
expect(retrieved).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,313 @@
import { users, userTimers, transactions } from "@db/schema";
import { eq, and, sql } from "drizzle-orm";
import { config } from "@shared/lib/config";
import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { TimerType, TransactionType } from "@shared/lib/constants";
import { DrizzleClient } from "@shared/db/DrizzleClient";
// OpenTDB API Response Types
interface OpenTDBResponse {
response_code: number;
results: Array<{
category: string;
type: 'boolean' | 'multiple';
difficulty: string;
question: string;
correct_answer: string;
incorrect_answers: string[];
}>;
}
export interface TriviaQuestion {
question: string;
correctAnswer: string;
incorrectAnswers: string[];
type: 'boolean' | 'multiple';
difficulty: string;
category: string;
}
export interface TriviaSession {
sessionId: string;
userId: string;
question: TriviaQuestion;
allAnswers: string[];
correctIndex: number;
expiresAt: Date;
entryFee: bigint;
potentialReward: bigint;
}
export interface TriviaResult {
correct: boolean;
reward: bigint;
correctAnswer: string;
}
class TriviaService {
private activeSessions: Map<string, TriviaSession> = new Map();
constructor() {
// Cleanup expired sessions every 30 seconds
setInterval(() => {
this.cleanupExpiredSessions();
}, 30000);
}
private cleanupExpiredSessions() {
const now = Date.now();
const expired: string[] = [];
for (const [sessionId, session] of this.activeSessions.entries()) {
if (session.expiresAt.getTime() < now) {
expired.push(sessionId);
}
}
for (const sessionId of expired) {
this.activeSessions.delete(sessionId);
}
if (expired.length > 0) {
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
}
}
/**
* Fetch a trivia question from OpenTDB API
*/
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
if (category) {
url += `&category=${category}`;
}
if (difficulty && difficulty !== 'random') {
url += `&difficulty=${difficulty}`;
}
try {
const response = await fetch(url);
const data = await response.json() as OpenTDBResponse;
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
throw new Error('Failed to fetch trivia question');
}
const result = data.results[0];
if (!result) {
throw new Error('No trivia question returned');
}
// Decode base64
return {
category: Buffer.from(result.category, 'base64').toString('utf-8'),
type: result.type,
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
question: Buffer.from(result.question, 'base64').toString('utf-8'),
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
incorrectAnswers: result.incorrect_answers.map(ans =>
Buffer.from(ans, 'base64').toString('utf-8')
),
};
} catch (error) {
console.error('[TriviaService] Error fetching question:', error);
throw new UserError('Failed to fetch trivia question. Please try again later.');
}
}
/**
* Check if user can play trivia (cooldown check)
*/
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
const now = new Date();
const cooldown = await DrizzleClient.query.userTimers.findFirst({
where: and(
eq(userTimers.userId, BigInt(userId)),
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
eq(userTimers.key, 'default')
),
});
if (cooldown && cooldown.expiresAt > now) {
return { canPlay: false, nextAvailable: cooldown.expiresAt };
}
return { canPlay: true };
}
/**
* Start a trivia session - deducts entry fee and creates session
*/
async startTrivia(userId: string, username: string, categoryId?: number): Promise<TriviaSession> {
// Check cooldown
const cooldownCheck = await this.canPlayTrivia(userId);
if (!cooldownCheck.canPlay) {
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
}
const entryFee = config.trivia.entryFee;
return await withTransaction(async (tx) => {
// Check balance
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId)),
});
if (!user) {
throw new UserError('User not found.');
}
if ((user.balance ?? 0n) < entryFee) {
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
}
// Deduct entry fee (SINK MECHANISM)
await tx.update(users)
.set({
balance: sql`${users.balance} - ${entryFee}`,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: -entryFee,
type: TransactionType.TRIVIA_ENTRY,
description: 'Trivia entry fee',
});
// Fetch question
let category = categoryId;
if (!category) {
category = config.trivia.categories.length > 0
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
: undefined;
}
const difficulty = config.trivia.difficulty;
const question = await this.fetchQuestion(category, difficulty);
// Shuffle answers
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
const correctIndex = shuffled.indexOf(question.correctAnswer);
// Create session
const sessionId = `${userId}_${Date.now()}`;
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
const session: TriviaSession = {
sessionId,
userId,
question,
allAnswers: shuffled,
correctIndex,
expiresAt,
entryFee,
potentialReward,
};
this.activeSessions.set(sessionId, session);
// Set cooldown
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
await tx.insert(userTimers)
.values({
userId: BigInt(userId),
type: TimerType.TRIVIA_COOLDOWN,
key: 'default',
expiresAt: cooldownEnd,
})
.onConflictDoUpdate({
target: [userTimers.userId, userTimers.type, userTimers.key],
set: { expiresAt: cooldownEnd },
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'info',
message: `${username} started a trivia game (${question.difficulty})`,
icon: '🎯'
});
return session;
});
}
/**
* Get session by ID
*/
getSession(sessionId: string): TriviaSession | undefined {
return this.activeSessions.get(sessionId);
}
/**
* Submit answer and process reward
*/
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new UserError('Session not found or expired.');
}
if (session.userId !== userId) {
throw new UserError('This is not your trivia question!');
}
// Remove session to prevent double-submit
this.activeSessions.delete(sessionId);
const reward = isCorrect ? session.potentialReward : 0n;
if (isCorrect) {
await withTransaction(async (tx) => {
// Award prize
await tx.update(users)
.set({
balance: (await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
}))!.balance! + reward,
})
.where(eq(users.id, BigInt(userId)));
// Record transaction
await tx.insert(transactions).values({
userId: BigInt(userId),
amount: reward,
type: TransactionType.TRIVIA_WIN,
description: 'Trivia prize',
});
// Record dashboard event
const user = await tx.query.users.findFirst({
where: eq(users.id, BigInt(userId))
});
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
icon: '🎉'
});
});
}
return {
correct: isCorrect,
reward,
correctAnswer: session.question.correctAnswer,
};
}
}
export const triviaService = new TriviaService();

56
shared/scripts/db-backup.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# =============================================================================
# Aurora Database Backup Script
# =============================================================================
set -e
# Load environment variables
if [ -f .env ]; then
set -a
source .env
set +a
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/shared/db/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/backup_$TIMESTAMP.sql"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "${YELLOW}💾 Starting database backup...${NC}"
mkdir -p "$BACKUP_DIR"
if docker ps | grep -q aurora_db; then
# Try to dump the database
if docker exec aurora_db pg_dump -U "${DB_USER:-auroradev}" "${DB_NAME:-auroradev}" > "$BACKUP_FILE"; then
# Check if backup file is not empty
if [ -s "$BACKUP_FILE" ]; then
echo -e " ${GREEN}${NC} Backup successful!"
echo -e " 📂 File: $BACKUP_FILE"
echo -e " 📏 Size: $(du -h "$BACKUP_FILE" | cut -f1)"
# Keep only last 10 backups
cd "$BACKUP_DIR"
ls -t backup_*.sql | tail -n +11 | xargs -r rm --
else
echo -e " ${RED}${NC} Backup created but empty. Something went wrong."
rm -f "$BACKUP_FILE"
exit 1
fi
else
echo -e " ${RED}${NC} pg_dump failed."
rm -f "$BACKUP_FILE"
exit 1
fi
else
echo -e " ${RED}${NC} Database container (aurora_db) is not running!"
exit 1
fi

64
shared/scripts/db-restore.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# =============================================================================
# Aurora Database Restore Script
# =============================================================================
# Usage: ./db-restore.sh [path/to/backup.sql]
# =============================================================================
set -e
# Load environment variables
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
if [ -z "$1" ]; then
echo -e "${RED}Error: Please specify the backup file to restore.${NC}"
echo "Usage: ./db-restore.sh <path-to-sql-file>"
echo "Available backups:"
ls -lh shared/db/backups/*.sql 2>/dev/null || echo " (No backups found in shared/db/backups)"
exit 1
fi
BACKUP_FILE="$1"
if [ ! -f "$BACKUP_FILE" ]; then
echo -e "${RED}Error: File not found: $BACKUP_FILE${NC}"
exit 1
fi
echo -e "${YELLOW}⚠️ WARNING: This will OVERWRITE the current database!${NC}"
echo -e "Target Database: ${DB_NAME:-auroradev}"
echo -e "Backup File: $BACKUP_FILE"
echo ""
read -p "Are you sure you want to proceed? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}♻️ Restoring database...${NC}"
if docker ps | grep -q aurora_db; then
# Drop and recreate public schema to ensure clean slate, then restore
# Note: dependent on how the dump was created. Standard pg_dump usually includes CREATE commands if configured,
# but often it's data only or structure+data.
# For safety, we'll just pipe the file to psql.
cat "$BACKUP_FILE" | docker exec -i aurora_db psql -U "${DB_USER:-auroradev}" -d "${DB_NAME:-auroradev}"
echo -e " ${GREEN}${NC} Restore complete!"
else
echo -e "${RED}Error: Database container (aurora_db) is not running!${NC}"
exit 1
fi
else
echo "Operation cancelled."
exit 0
fi

131
shared/scripts/deploy.sh Normal file
View File

@@ -0,0 +1,131 @@
#!/bin/bash
# =============================================================================
# Aurora Production Deployment Script
# =============================================================================
# Run this script to deploy the latest version of Aurora
# Usage: bash deploy.sh
# =============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Aurora Deployment Script ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
echo ""
cd "$PROJECT_DIR"
# =============================================================================
# Pre-flight Checks
# =============================================================================
echo -e "${YELLOW}[1/5] Running pre-flight checks...${NC}"
# Check if .env exists
if [ ! -f .env ]; then
echo -e "${RED}Error: .env file not found${NC}"
exit 1
fi
# Check if Docker is running
if ! docker info &>/dev/null; then
echo -e "${RED}Error: Docker is not running${NC}"
exit 1
fi
echo -e " ${GREEN}${NC} Pre-flight checks passed"
# =============================================================================
# Backup Database (optional but recommended)
# =============================================================================
echo -e "${YELLOW}[2/5] Creating database backup...${NC}"
BACKUP_DIR="$PROJECT_DIR/shared/db/backups"
mkdir -p "$BACKUP_DIR"
if docker ps | grep -q aurora_db; then
BACKUP_FILE="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).sql"
docker exec aurora_db pg_dump -U "${DB_USER:-auroradev}" "${DB_NAME:-auroradev}" > "$BACKUP_FILE" 2>/dev/null || true
if [ -f "$BACKUP_FILE" ] && [ -s "$BACKUP_FILE" ]; then
echo -e " ${GREEN}${NC} Database backed up to: $BACKUP_FILE"
else
echo -e " ${YELLOW}${NC} Database backup skipped (container not running or empty)"
rm -f "$BACKUP_FILE"
fi
else
echo -e " ${YELLOW}${NC} Database backup skipped (container not running)"
fi
# =============================================================================
# Pull Latest Code (if using git)
# =============================================================================
echo -e "${YELLOW}[3/5] Pulling latest code...${NC}"
if [ -d .git ]; then
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || echo " Skipping git pull"
echo -e " ${GREEN}${NC} Code updated"
else
echo -e " ${YELLOW}${NC} Not a git repository, skipping pull"
fi
# =============================================================================
# Build and Deploy
# =============================================================================
echo -e "${YELLOW}[4/5] Building and deploying containers...${NC}"
# Build the new image
docker compose -f docker-compose.prod.yml build --no-cache
# Stop and remove old containers, start new ones
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
echo -e " ${GREEN}${NC} Containers deployed"
# =============================================================================
# Health Check
# =============================================================================
echo -e "${YELLOW}[5/5] Waiting for health checks...${NC}"
sleep 10
# Check container status
if docker ps | grep -q "aurora_app.*healthy"; then
echo -e " ${GREEN}${NC} aurora_app is healthy"
else
echo -e " ${YELLOW}${NC} aurora_app health check pending (may take up to 60s)"
fi
if docker ps | grep -q "aurora_db.*healthy"; then
echo -e " ${GREEN}${NC} aurora_db is healthy"
else
echo -e " ${YELLOW}${NC} aurora_db health check pending"
fi
# =============================================================================
# Cleanup
# =============================================================================
echo ""
echo -e "${YELLOW}Cleaning up old Docker images...${NC}"
docker image prune -f
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Deployment Complete! 🚀 ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e "Container Status:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep aurora
echo ""
echo -e "View logs with: ${YELLOW}docker logs -f aurora_app${NC}"

View File

@@ -0,0 +1,98 @@
#!/bin/bash
# Cleanup script for Docker resources
# Use: ./shared/scripts/docker-cleanup.sh
# Use: ./shared/scripts/docker-cleanup.sh --full (for aggressive cleanup)
set -e
echo "🧹 Aurora Docker Cleanup"
echo "========================"
echo ""
# Show current disk usage first
echo "📊 Current Docker disk usage:"
docker system df
echo ""
# Stop running containers for this project
echo "📦 Stopping Aurora containers..."
docker compose down 2>/dev/null || true
# Remove dangling images (untagged images from failed builds)
echo ""
echo "🗑️ Removing dangling images..."
docker image prune -f
# Check for --full flag for aggressive cleanup
if [[ "$1" == "--full" ]]; then
echo ""
echo "🔥 Full cleanup mode - removing all unused Docker resources..."
# Remove all unused images, not just dangling ones
echo " → Removing unused images..."
docker image prune -a -f
# Remove build cache
echo " → Removing build cache..."
docker builder prune -a -f
# Remove unused volumes (except named ones we need)
echo " → Removing unused volumes..."
docker volume prune -f
# Remove unused networks
echo " → Removing unused networks..."
docker network prune -f
# Remove node_modules volumes
echo " → Removing node_modules volumes..."
docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true
echo ""
echo "✅ Full cleanup complete!"
else
# Interactive mode
echo ""
read -p "🔧 Remove Docker build cache? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker builder prune -f
echo "✓ Build cache cleared"
fi
echo ""
read -p "🖼️ Remove ALL unused images (not just dangling)? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker image prune -a -f
echo "✓ Unused images removed"
fi
echo ""
read -p "📁 Remove node_modules volumes? (forces fresh install) (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true
echo "✓ Node modules volumes removed"
fi
echo ""
read -p "🧨 Run full system prune (removes ALL unused data)? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker system prune -a -f --volumes
echo "✓ Full system prune complete"
fi
echo ""
echo "✅ Cleanup complete!"
fi
echo ""
echo "📊 Docker disk usage after cleanup:"
docker system df
echo ""
echo "💡 Tip: Check container logs with: sudo du -sh /var/lib/docker/containers/*/*.log"
echo "💡 Tip: Truncate logs with: sudo truncate -s 0 /var/lib/docker/containers/*/*.log"
echo ""
echo "Run 'docker compose up --build' to rebuild"

38
shared/scripts/logs.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# =============================================================================
# Aurora Log Viewer
# =============================================================================
# Usage: ./logs.sh [app|db|all] [-f]
# Default: app container, follow mode
# =============================================================================
SERVICE=${1:-app}
FOLLOW="-f"
if [[ "$1" == "-f" ]]; then
SERVICE="app"
FOLLOW="-f"
elif [[ "$2" == "-f" ]]; then
FOLLOW="-f"
elif [[ "$2" == "--no-follow" ]]; then
FOLLOW=""
fi
echo "📋 Fetching logs for service: $SERVICE..."
case $SERVICE in
app)
docker compose logs $FOLLOW app
;;
db)
docker compose logs $FOLLOW db
;;
all)
docker compose logs $FOLLOW
;;
*)
echo "Unknown service: $SERVICE"
echo "Usage: ./logs.sh [app|db|all] [-f]"
exit 1
;;
esac

View File

@@ -1,43 +0,0 @@
#!/bin/bash
# Load environment variables
if [ -f .env ]; then
set -a
source .env
set +a
fi
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
echo "Error: VPS_HOST and VPS_USER must be set in .env"
echo "Please add them to your .env file:"
echo "VPS_USER=your-username"
echo "VPS_HOST=your-ip-address"
exit 1
fi
DASHBOARD_PORT=${DASHBOARD_PORT:-3000}
echo "🌐 Establishing secure tunnel to Aurora Dashboard..."
echo "📊 Dashboard will be accessible at: http://localhost:$DASHBOARD_PORT"
echo "Press Ctrl+C to stop the connection."
echo ""
# Function to open browser (cross-platform)
open_browser() {
sleep 2
if command -v open &> /dev/null; then
open "http://localhost:$DASHBOARD_PORT"
elif command -v xdg-open &> /dev/null; then
xdg-open "http://localhost:$DASHBOARD_PORT"
fi
}
# Check if autossh is available
if command -v autossh &> /dev/null; then
SSH_CMD="autossh -M 0 -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
else
SSH_CMD="ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3"
fi
open_browser &
$SSH_CMD -N -L $DASHBOARD_PORT:127.0.0.1:$DASHBOARD_PORT $VPS_USER@$VPS_HOST

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# Load environment variables
if [ -f .env ]; then
# export $(grep -v '^#' .env | xargs) # Use a safer way if possible, but for simple .env this often works.
# Better way to source .env without exporting everything to shell if we just want to use them in script:
set -a
source .env
set +a
fi
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ]; then
echo "Error: VPS_HOST and VPS_USER must be set in .env"
echo "Please add them to your .env file:"
echo "VPS_USER=your-username"
echo "VPS_HOST=your-ip-address"
exit 1
fi
echo "🔮 Establishing secure tunnel to Drizzle Studio..."
echo "📚 Studio will be accessible at: https://local.drizzle.studio"
echo "Press Ctrl+C to stop the connection."
# -N means "Do not execute a remote command". -L is for local port forwarding.
ssh -N -L 4983:127.0.0.1:4983 $VPS_USER@$VPS_HOST

View File

@@ -0,0 +1,160 @@
#!/bin/bash
# =============================================================================
# Server Setup Script for Aurora Production Deployment
# =============================================================================
# Run this script ONCE on a fresh server to configure security settings.
# Usage: sudo bash setup-server.sh
# =============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Aurora Server Security Setup Script ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Please run as root (sudo)${NC}"
exit 1
fi
# =============================================================================
# 1. Create Deploy User
# =============================================================================
echo -e "${YELLOW}[1/5] Creating deploy user...${NC}"
DEPLOY_USER="deploy"
if id "$DEPLOY_USER" &>/dev/null; then
echo -e " User '$DEPLOY_USER' already exists, skipping..."
else
adduser --disabled-password --gecos "" $DEPLOY_USER
echo -e " ${GREEN}${NC} Created user '$DEPLOY_USER'"
fi
# Add to docker group
usermod -aG docker $DEPLOY_USER 2>/dev/null || groupadd docker && usermod -aG docker $DEPLOY_USER
echo -e " ${GREEN}${NC} Added '$DEPLOY_USER' to docker group"
# Add to sudo group (optional - remove if you don't want sudo access)
usermod -aG sudo $DEPLOY_USER
echo -e " ${GREEN}${NC} Added '$DEPLOY_USER' to sudo group"
# Copy SSH keys from root to deploy user
if [ -d /root/.ssh ]; then
mkdir -p /home/$DEPLOY_USER/.ssh
cp /root/.ssh/authorized_keys /home/$DEPLOY_USER/.ssh/ 2>/dev/null || true
chown -R $DEPLOY_USER:$DEPLOY_USER /home/$DEPLOY_USER/.ssh
chmod 700 /home/$DEPLOY_USER/.ssh
chmod 600 /home/$DEPLOY_USER/.ssh/authorized_keys 2>/dev/null || true
echo -e " ${GREEN}${NC} Copied SSH keys to '$DEPLOY_USER'"
fi
# =============================================================================
# 2. Configure UFW Firewall
# =============================================================================
echo -e "${YELLOW}[2/5] Configuring UFW firewall...${NC}"
apt-get update -qq
apt-get install -y -qq ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
# Add more rules as needed:
# ufw allow 80/tcp # HTTP
# ufw allow 443/tcp # HTTPS
# Enable UFW (non-interactive)
echo "y" | ufw enable
echo -e " ${GREEN}${NC} UFW firewall enabled and configured"
# =============================================================================
# 3. Install and Configure Fail2ban
# =============================================================================
echo -e "${YELLOW}[3/5] Installing fail2ban...${NC}"
apt-get install -y -qq fail2ban
# Create local jail configuration
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 24h
EOF
systemctl enable fail2ban
systemctl restart fail2ban
echo -e " ${GREEN}${NC} Fail2ban installed and configured"
# =============================================================================
# 4. Harden SSH Configuration
# =============================================================================
echo -e "${YELLOW}[4/5] Hardening SSH configuration...${NC}"
SSHD_CONFIG="/etc/ssh/sshd_config"
# Backup original config
cp $SSHD_CONFIG ${SSHD_CONFIG}.backup
# Apply hardening settings
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' $SSHD_CONFIG
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' $SSHD_CONFIG
sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' $SSHD_CONFIG
sed -i 's/^#\?X11Forwarding.*/X11Forwarding no/' $SSHD_CONFIG
sed -i 's/^#\?MaxAuthTries.*/MaxAuthTries 3/' $SSHD_CONFIG
# Validate SSH config before restarting
if sshd -t; then
systemctl reload sshd
echo -e " ${GREEN}${NC} SSH hardened (root login disabled, password auth disabled)"
else
echo -e " ${RED}${NC} SSH config validation failed, restoring backup..."
cp ${SSHD_CONFIG}.backup $SSHD_CONFIG
fi
# =============================================================================
# 5. System Updates
# =============================================================================
echo -e "${YELLOW}[5/5] Installing system updates...${NC}"
apt-get upgrade -y -qq
apt-get autoremove -y -qq
echo -e " ${GREEN}${NC} System updated"
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete! ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e "Next steps:"
echo -e " 1. Update your local .env file:"
echo -e " ${YELLOW}VPS_USER=deploy${NC}"
echo -e ""
echo -e " 2. Test SSH access with the new user:"
echo -e " ${YELLOW}ssh deploy@<your-server-ip>${NC}"
echo -e ""
echo -e " 3. Deploy the application:"
echo -e " ${YELLOW}cd /home/deploy/Aurora && docker compose -f docker-compose.prod.yml up -d${NC}"
echo ""
echo -e "${RED}⚠️ IMPORTANT: Test SSH access with 'deploy' user BEFORE logging out!${NC}"
echo -e "${RED} Keep this root session open until you confirm 'deploy' user works.${NC}"

View File

@@ -13,6 +13,7 @@
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"noEmit": true,
// Best practices
"strict": true,

View File

@@ -127,28 +127,119 @@ const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const build = async () => {
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
publicPath: "/", // Use absolute paths for SPA routing compatibility
define: {
"process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"),
},
...cliConfig,
});
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
return result;
};
const result = await build();
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
if ((cliConfig as any).watch) {
console.log("👀 Watching for changes...\n");
// Polling-based file watcher for Docker compatibility
// Docker volumes don't propagate filesystem events (inotify) reliably
const srcDir = path.join(process.cwd(), "src");
const POLL_INTERVAL_MS = 1000;
let lastMtimes = new Map<string, number>();
let isRebuilding = false;
// Collect all file mtimes in src directory
const collectMtimes = async (): Promise<Map<string, number>> => {
const mtimes = new Map<string, number>();
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,css,html}");
for await (const file of glob.scan({ cwd: srcDir, absolute: true })) {
try {
const stat = await Bun.file(file).stat();
if (stat) {
mtimes.set(file, stat.mtime.getTime());
}
} catch {
// File may have been deleted, skip
}
}
return mtimes;
};
// Initial collection
lastMtimes = await collectMtimes();
// Polling loop
const poll = async () => {
if (isRebuilding) return;
const currentMtimes = await collectMtimes();
const changedFiles: string[] = [];
// Check for new or modified files
for (const [file, mtime] of currentMtimes) {
const lastMtime = lastMtimes.get(file);
if (lastMtime === undefined || lastMtime < mtime) {
changedFiles.push(path.relative(srcDir, file));
}
}
// Check for deleted files
for (const file of lastMtimes.keys()) {
if (!currentMtimes.has(file)) {
changedFiles.push(path.relative(srcDir, file) + " (deleted)");
}
}
if (changedFiles.length > 0) {
isRebuilding = true;
console.log(`\n🔄 Changes detected:`);
changedFiles.forEach(f => console.log(`${f}`));
console.log("");
try {
const rebuildStart = performance.now();
await build();
const rebuildEnd = performance.now();
console.log(`\n✅ Rebuild completed in ${(rebuildEnd - rebuildStart).toFixed(2)}ms\n`);
} catch (err) {
console.error("❌ Rebuild failed:", err);
}
lastMtimes = currentMtimes;
isRebuilding = false;
}
};
const interval = setInterval(poll, POLL_INTERVAL_MS);
// Handle manual exit
process.on("SIGINT", () => {
clearInterval(interval);
console.log("\n👋 Stopping build watcher...");
process.exit(0);
});
// Keep process alive
process.stdin.resume();
}

View File

@@ -5,20 +5,32 @@
"": {
"name": "bun-react-template",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5",
},
"devDependencies": {
"@types/bun": "latest",
@@ -38,6 +50,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg=="],
@@ -64,8 +78,12 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@@ -78,6 +96,8 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
@@ -86,6 +106,8 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -94,12 +116,20 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -122,14 +152,40 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
@@ -146,16 +202,56 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -166,14 +262,26 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
@@ -184,12 +292,20 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -197,5 +313,7 @@
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
}
}

View File

@@ -9,20 +9,32 @@
"build": "bun run build.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react-hook-form": "^7.70.0",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.3.1"
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/react": "^19",

View File

@@ -1,22 +1,52 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { DashboardLayout } from "./layouts/DashboardLayout";
import { Dashboard } from "./pages/Dashboard";
import { Activity } from "./pages/Activity";
import { Settings } from "./pages/Settings";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import "./index.css";
import { DesignSystem } from "./pages/DesignSystem";
import { AdminQuests } from "./pages/AdminQuests";
import { AdminOverview } from "./pages/admin/Overview";
import { AdminItems } from "./pages/admin/Items";
import { Home } from "./pages/Home";
import { Toaster } from "sonner";
import { NavigationProvider } from "./contexts/navigation-context";
import { MainLayout } from "./components/layout/main-layout";
import { SettingsLayout } from "./pages/settings/SettingsLayout";
import { GeneralSettings } from "./pages/settings/General";
import { EconomySettings } from "./pages/settings/Economy";
import { SystemsSettings } from "./pages/settings/Systems";
import { RolesSettings } from "./pages/settings/Roles";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<DashboardLayout />}>
<Route index element={<Dashboard />} />
<Route path="activity" element={<Activity />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
<NavigationProvider>
<Toaster richColors position="top-right" theme="dark" />
<MainLayout>
<Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin/overview" element={<AdminOverview />} />
<Route path="/admin/quests" element={<AdminQuests />} />
<Route path="/admin/items" element={<AdminItems />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="economy" element={<EconomySettings />} />
<Route path="systems" element={<SystemsSettings />} />
<Route path="roles" element={<RolesSettings />} />
</Route>
<Route path="/" element={<Home />} />
</Routes>
</MainLayout>
</NavigationProvider>
</BrowserRouter>
);
}
export default App;

View File

@@ -1,96 +0,0 @@
import { LayoutDashboard, Settings, Activity, Server, Zap } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarFooter,
SidebarRail,
} from "@/components/ui/sidebar";
// Menu items.
const items = [
{
title: "Dashboard",
url: "/",
icon: LayoutDashboard,
},
{
title: "Activity",
url: "/activity",
icon: Activity,
},
{
title: "Settings",
url: "/settings",
icon: Settings,
},
];
export function AppSidebar() {
const location = useLocation();
return (
<Sidebar>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Zap className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">Aurora</span>
<span className="">v1.0.0</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={location.pathname === item.url}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<div className="bg-muted flex aspect-square size-8 items-center justify-center rounded-lg">
<span className="text-xs font-bold">U</span>
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">User</span>
<span className="text-xs text-muted-foreground">Admin</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useState } from "react";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Activity } from "lucide-react";
import { cn } from "../lib/utils";
import type { ActivityData } from "@shared/modules/dashboard/dashboard.types";
interface ActivityChartProps {
className?: string;
data?: ActivityData[];
}
export function ActivityChart({ className, data: providedData }: ActivityChartProps) {
const [data, setData] = useState<any[]>([]); // using any[] for the displayTime extension
const [isLoading, setIsLoading] = useState(!providedData);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (providedData) {
// Process provided data
const formatted = providedData.map((item) => ({
...item,
displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00',
}));
setData(formatted);
return;
}
let mounted = true;
async function fetchActivity() {
try {
const response = await fetch("/api/stats/activity");
if (!response.ok) throw new Error("Failed to fetch activity data");
const result = await response.json();
if (mounted) {
// Normalize data: ensure we have 24 hours format
// The API returns { hour: ISOString, commands: number, transactions: number }
// We want to format hour to readable time
const formatted = result.map((item: ActivityData) => ({
...item,
displayTime: new Date(item.hour).getHours().toString().padStart(2, '0') + ':00',
}));
// Sort by time just in case, though API should handle it
setData(formatted);
// Only set loading to false on the first load to avoid flickering
setIsLoading(false);
}
} catch (err) {
if (mounted) {
console.error(err);
setError("Failed to load activity data");
setIsLoading(false);
}
}
}
fetchActivity();
// Refresh every 60 seconds
const interval = setInterval(fetchActivity, 60000);
return () => {
mounted = false;
clearInterval(interval);
};
}, [providedData]);
if (error) {
return (
<Card className={cn("glass-card", className)}>
<CardContent className="flex items-center justify-center h-[300px] text-destructive">
{error}
</CardContent>
</Card>
);
}
return (
<Card className={cn("glass-card overflow-hidden", className)}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
<CardTitle>24h Activity</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="h-[250px] w-full">
{isLoading ? (
<div className="h-full w-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 10,
right: 10,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorCommands" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--primary)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--primary)" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorTx" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--secondary)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--secondary)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" />
<XAxis
dataKey="displayTime"
stroke="var(--muted-foreground)"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
borderColor: "var(--border)",
borderRadius: "calc(var(--radius) + 2px)",
color: "var(--foreground)"
}}
itemStyle={{ color: "var(--foreground)" }}
/>
<Area
type="monotone"
dataKey="commands"
name="Commands"
stroke="var(--primary)"
fillOpacity={1}
fill="url(#colorCommands)"
/>
<Area
type="monotone"
dataKey="transactions"
name="Transactions"
stroke="var(--secondary)"
fillOpacity={1}
fill="url(#colorTx)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,235 @@
import React, { useEffect, useState, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "./ui/sheet";
import { ScrollArea } from "./ui/scroll-area";
import { Switch } from "./ui/switch";
import { Badge } from "./ui/badge";
import { Loader2, Terminal, Sparkles, Coins, Shield, Backpack, TrendingUp, MessageSquare, User } from "lucide-react";
import { cn } from "../lib/utils";
import { toast } from "sonner";
interface Command {
name: string;
category: string;
}
interface CommandsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Category metadata for visual styling
const CATEGORY_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
admin: { label: "Admin", color: "bg-red-500/20 text-red-400 border-red-500/30", icon: Shield },
economy: { label: "Economy", color: "bg-amber-500/20 text-amber-400 border-amber-500/30", icon: Coins },
leveling: { label: "Leveling", color: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", icon: TrendingUp },
inventory: { label: "Inventory", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Backpack },
quest: { label: "Quests", color: "bg-purple-500/20 text-purple-400 border-purple-500/30", icon: Sparkles },
feedback: { label: "Feedback", color: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", icon: MessageSquare },
user: { label: "User", color: "bg-pink-500/20 text-pink-400 border-pink-500/30", icon: User },
uncategorized: { label: "Other", color: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", icon: Terminal },
};
export function CommandsDrawer({ open, onOpenChange }: CommandsDrawerProps) {
const [commands, setCommands] = useState<Command[]>([]);
const [enabledState, setEnabledState] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
// Fetch commands and their enabled state
useEffect(() => {
if (open) {
setLoading(true);
Promise.all([
fetch("/api/settings/meta").then(res => res.json()),
fetch("/api/settings").then(res => res.json()),
]).then(([meta, config]) => {
setCommands(meta.commands || []);
// Build enabled state from config.commands (undefined = enabled by default)
const state: Record<string, boolean> = {};
for (const cmd of meta.commands || []) {
state[cmd.name] = config.commands?.[cmd.name] !== false;
}
setEnabledState(state);
}).catch(err => {
toast.error("Failed to load commands", {
description: "Unable to fetch command list. Please try again."
});
console.error(err);
}).finally(() => {
setLoading(false);
});
}
}, [open]);
// Group commands by category
const groupedCommands = useMemo(() => {
const groups: Record<string, Command[]> = {};
for (const cmd of commands) {
const cat = cmd.category || "uncategorized";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(cmd);
}
// Sort categories: admin first, then alphabetically
const sortedCategories = Object.keys(groups).sort((a, b) => {
if (a === "admin") return -1;
if (b === "admin") return 1;
return a.localeCompare(b);
});
return sortedCategories.map(cat => ({ category: cat, commands: groups[cat]! }));
}, [commands]);
// Toggle command enabled state
const toggleCommand = async (commandName: string, enabled: boolean) => {
setSaving(commandName);
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
commands: {
[commandName]: enabled,
},
}),
});
if (!response.ok) throw new Error("Failed to save");
setEnabledState(prev => ({ ...prev, [commandName]: enabled }));
toast.success(`/${commandName} ${enabled ? "enabled" : "disabled"}`, {
description: `Command has been ${enabled ? "enabled" : "disabled"} successfully.`,
duration: 2000,
id: "command-toggle",
});
} catch (error) {
toast.error("Failed to toggle command", {
description: "Unable to update command status. Please try again."
});
console.error(error);
} finally {
setSaving(null);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
<SheetContent side="right" className="w-[800px] sm:max-w-[800px] p-0 flex flex-col gap-0 border-l border-border/50 glass-card bg-background/95 text-foreground">
<SheetHeader className="p-6 border-b border-border/50">
<SheetTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
Command Manager
</SheetTitle>
<SheetDescription>
Enable or disable commands. Changes take effect immediately.
</SheetDescription>
</SheetHeader>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="space-y-6 p-6 pb-8">
{groupedCommands.map(({ category, commands: cmds }) => {
const config = (CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.uncategorized)!;
const IconComponent = config.icon;
return (
<div key={category} className="space-y-3">
{/* Category Header */}
<div className="flex items-center gap-2">
<IconComponent className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{config.label}
</h3>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{cmds.length}
</Badge>
</div>
{/* Commands Grid */}
<div className="grid grid-cols-2 gap-3">
{cmds.map(cmd => {
const isEnabled = enabledState[cmd.name] !== false;
const isSaving = saving === cmd.name;
return (
<div
key={cmd.name}
className={cn(
"group relative rounded-lg overflow-hidden transition-all duration-300",
"bg-gradient-to-r from-card/80 to-card/40",
"border border-border/20 hover:border-border/40",
"hover:shadow-lg hover:shadow-primary/5",
"hover:translate-x-1",
!isEnabled && "opacity-40 grayscale",
isSaving && "animate-pulse"
)}
>
{/* Category color accent bar */}
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 transition-all duration-300",
config.color.split(' ')[0],
"group-hover:w-1.5"
)} />
<div className="p-3 pl-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Icon with glow effect */}
<div className={cn(
"w-9 h-9 rounded-lg flex items-center justify-center",
"bg-gradient-to-br",
config.color,
"shadow-sm",
isEnabled && "group-hover:shadow-md group-hover:scale-105",
"transition-all duration-300"
)}>
<IconComponent className="w-4 h-4" />
</div>
<div className="flex flex-col">
<span className={cn(
"font-mono text-sm font-semibold tracking-tight",
"transition-colors duration-300",
isEnabled ? "text-foreground" : "text-muted-foreground"
)}>
/{cmd.name}
</span>
<span className="text-[10px] text-muted-foreground/70 uppercase tracking-wider">
{category}
</span>
</div>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => toggleCommand(cmd.name, checked)}
disabled={isSaving}
className={cn(
"transition-opacity duration-300",
!isEnabled && "opacity-60"
)}
/>
</div>
</div>
);
})}
</div>
</div>
);
})}
{groupedCommands.length === 0 && (
<div className="text-center text-muted-foreground py-8">
No commands found.
</div>
)}
</div>
</ScrollArea>
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,48 @@
import React, { type ReactNode } from "react";
import { cn } from "../lib/utils";
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
import { Badge } from "./ui/badge";
interface FeatureCardProps {
title: string;
category: string;
description?: string;
icon?: ReactNode;
children?: ReactNode;
className?: string;
delay?: number; // Animation delay in ms or generic unit
}
export function FeatureCard({
title,
category,
description,
icon,
children,
className,
}: FeatureCardProps) {
return (
<Card className={cn(
"glass-card border-none hover-lift transition-all animate-in slide-up group overflow-hidden",
className
)}>
{icon && (
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
{icon}
</div>
)}
<CardHeader>
<Badge variant="glass" className="w-fit mb-2">{category}</Badge>
<CardTitle className="text-xl text-primary">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{description && (
<p className="text-muted-foreground text-step--1">
{description}
</p>
)}
{children}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import React, { type ReactNode } from "react";
import { cn } from "../lib/utils";
interface InfoCardProps {
icon: ReactNode;
title: string;
description: string;
iconWrapperClassName?: string;
className?: string;
}
export function InfoCard({
icon,
title,
description,
iconWrapperClassName,
className,
}: InfoCardProps) {
return (
<div className={cn("space-y-4 p-6 glass-card rounded-2xl hover:bg-white/5 transition-colors", className)}>
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center mb-4", iconWrapperClassName)}>
{icon}
</div>
<h3 className="text-xl font-bold text-primary">{title}</h3>
<p className="text-muted-foreground text-step--1">
{description}
</p>
</div>
);
}

View File

@@ -0,0 +1,220 @@
import { Link } from "react-router-dom"
import { ChevronRight } from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarRail,
useSidebar,
} from "@/components/ui/sidebar"
import { useNavigation, type NavItem } from "@/contexts/navigation-context"
import { cn } from "@/lib/utils"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useSocket } from "@/hooks/use-socket"
function NavItemWithSubMenu({ item }: { item: NavItem }) {
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
// When collapsed, show a dropdown menu on hover/click
if (isCollapsed) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="start"
sideOffset={8}
className="min-w-[180px] bg-background/95 backdrop-blur-xl border-border/50"
>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{item.title}
</div>
{item.subItems?.map((subItem) => (
<DropdownMenuItem key={subItem.title} asChild className="group/dropitem">
<Link
to={subItem.url}
className={cn(
"cursor-pointer py-4 min-h-10 flex items-center gap-2",
subItem.isActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-inherit"
)}
>
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/dropitem:text-inherit")} />
{subItem.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
// When expanded, show collapsible sub-menu
return (
<Collapsible defaultOpen={item.isActive} className="group/collapsible">
<SidebarMenuItem className="flex flex-col">
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium py-4 min-h-10",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>
{item.title}
</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<SidebarMenuSub>
{item.subItems?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={subItem.isActive}
className={cn(
"transition-all duration-200 py-4 min-h-10 group/subitem",
subItem.isActive
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-inherit"
)}
>
<Link to={subItem.url}>
<subItem.icon className={cn("size-4", subItem.isActive ? "text-primary" : "text-muted-foreground group-hover/subitem:text-inherit")} />
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}
function NavItemLink({ item }: { item: NavItem }) {
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={item.isActive}
tooltip={item.title}
className={cn(
"transition-all duration-200 ease-in-out font-medium",
item.isActive
? "bg-primary/10 text-primary shadow-[inset_4px_0_0_0_hsl(var(--primary))] hover:bg-primary/15 hover:text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/5"
)}
>
<Link to={item.url} className="flex items-center gap-3 py-4 min-h-10 group-data-[collapsible=icon]:justify-center">
<item.icon className={cn("size-5", item.isActive && "text-primary fill-primary/20")} />
<span className={cn("group-data-[collapsible=icon]:hidden", item.isActive && "text-primary")}>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
export function AppSidebar() {
const { navItems } = useNavigation()
const { stats } = useSocket()
return (
<Sidebar collapsible="icon" className="border-r border-border/50 bg-background/60 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
<SidebarHeader className="pb-4 pt-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild className="hover:bg-primary/10 transition-colors">
<Link to="/">
{stats?.bot?.avatarUrl ? (
<img
src={stats.bot.avatarUrl}
alt={stats.bot.name}
className="size-10 rounded-full group-data-[collapsible=icon]:size-8 object-cover shadow-lg"
/>
) : (
<div className="flex aspect-square size-10 items-center justify-center rounded-full bg-aurora sun-flare shadow-lg group-data-[collapsible=icon]:size-8">
<span className="sr-only">Aurora</span>
</div>
)}
<div className="grid flex-1 text-left text-sm leading-tight ml-2 group-data-[collapsible=icon]:hidden">
<span className="truncate font-bold text-primary text-base">Aurora</span>
<span className="truncate text-xs text-muted-foreground font-medium">
{stats?.bot?.status || "Online"}
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="px-2 group-data-[collapsible=icon]:px-0">
<SidebarGroup>
<SidebarGroupLabel className="text-muted-foreground/70 uppercase tracking-wider text-xs font-bold mb-2 px-2 group-data-[collapsible=icon]:hidden">
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-2 group-data-[collapsible=icon]:items-center">
{navItems.map((item) => (
item.subItems ? (
<NavItemWithSubMenu key={item.title} item={item} />
) : (
<NavItemLink key={item.title} item={item} />
)
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}

Some files were not shown because too many files have changed in this diff Show More