Compare commits
15 Commits
c807fd4fd0
...
feat/web-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 | ||
|
|
dd62336571 | ||
|
|
8280111b66 | ||
|
|
34347f0c63 |
57
.agent/workflows/create-ticket.md
Normal file
57
.agent/workflows/create-ticket.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
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: ...
|
||||||
53
.agent/workflows/review.md
Normal file
53
.agent/workflows/review.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
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: ...
|
||||||
50
.agent/workflows/work-on-ticket.md
Normal file
50
.agent/workflows/work-on-ticket.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,4 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME}
|
- POSTGRES_DB=${DB_NAME}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT}:5432"
|
- "127.0.0.1:${DB_PORT}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/db/data:/var/lib/postgresql/data
|
- ./src/db/data:/var/lib/postgresql/data
|
||||||
- ./src/db/log:/var/log/postgresql
|
- ./src/db/log:/var/log/postgresql
|
||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "4983:4983"
|
- "127.0.0.1:4983:4983"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.7"
|
||||||
"postgres": "^3.4.7"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
@@ -31,7 +32,7 @@ export const note = createCommand({
|
|||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await ModerationService.createCase({
|
||||||
type: 'note',
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { userTimers, users } from "@/db/schema";
|
|||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { config } from "@lib/config";
|
import { config } from "@lib/config";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
const EXAM_TIMER_TYPE = 'EXAM_SYSTEM';
|
const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM;
|
||||||
const EXAM_TIMER_KEY = 'default';
|
const EXAM_TIMER_KEY = 'default';
|
||||||
|
|
||||||
interface ExamMetadata {
|
interface ExamMetadata {
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
|
|||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import { inventory, items } from "@/db/schema";
|
|
||||||
import { eq, and, like } from "drizzle-orm";
|
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import type { ItemUsageData } from "@/lib/types";
|
import type { ItemUsageData } from "@/lib/types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
@@ -75,28 +72,8 @@ export const use = createCommand({
|
|||||||
const focusedValue = interaction.options.getFocused();
|
const focusedValue = interaction.options.getFocused();
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
|
|
||||||
// Fetch owned items that match the search query
|
const results = await inventoryService.getAutocompleteItems(userId, focusedValue);
|
||||||
// We join with items table to filter by name directly in the database
|
|
||||||
const entries = await DrizzleClient.select({
|
|
||||||
quantity: inventory.quantity,
|
|
||||||
item: items
|
|
||||||
})
|
|
||||||
.from(inventory)
|
|
||||||
.innerJoin(items, eq(inventory.itemId, items.id))
|
|
||||||
.where(and(
|
|
||||||
eq(inventory.userId, BigInt(userId)),
|
|
||||||
like(items.name, `%${focusedValue}%`)
|
|
||||||
))
|
|
||||||
.limit(20); // Fetch up to 20 matching items
|
|
||||||
|
|
||||||
const filtered = entries.filter(entry => {
|
await interaction.respond(results);
|
||||||
const usageData = entry.item.usageData as ItemUsageData | null;
|
|
||||||
const isUsable = usageData && usageData.effects && usageData.effects.length > 0;
|
|
||||||
return isUsable;
|
|
||||||
});
|
|
||||||
|
|
||||||
await interaction.respond(
|
|
||||||
filtered.map(entry => ({ name: `${entry.item.name} (${entry.quantity})`, value: entry.item.id }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'users'
|
WHERE tablename = 'users'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("users_balance_idx");
|
expect(indexNames).toContain("users_balance_idx");
|
||||||
expect(indexNames).toContain("users_level_xp_idx");
|
expect(indexNames).toContain("users_level_xp_idx");
|
||||||
});
|
});
|
||||||
@@ -17,7 +17,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'transactions'
|
WHERE tablename = 'transactions'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("transactions_created_at_idx");
|
expect(indexNames).toContain("transactions_created_at_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'moderation_cases'
|
WHERE tablename = 'moderation_cases'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
||||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'user_timers'
|
WHERE tablename = 'user_timers'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/index.ts
12
src/index.ts
@@ -1,11 +1,14 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
|
|
||||||
|
import { WebServer } from "@/web/server";
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
|
||||||
|
WebServer.start();
|
||||||
|
|
||||||
// login with the token from .env
|
// login with the token from .env
|
||||||
if (!env.DISCORD_BOT_TOKEN) {
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
@@ -14,5 +17,10 @@ if (!env.DISCORD_BOT_TOKEN) {
|
|||||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on("SIGINT", () => AuroraClient.shutdown());
|
const shutdownHandler = () => {
|
||||||
process.on("SIGTERM", () => AuroraClient.shutdown());
|
WebServer.stop();
|
||||||
|
AuroraClient.shutdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
process.on("SIGTERM", shutdownHandler);
|
||||||
@@ -69,12 +69,7 @@ export interface GameConfigType {
|
|||||||
autoTimeoutThreshold?: number;
|
autoTimeoutThreshold?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
system: {
|
system: Record<string, any>;
|
||||||
cleanup: {
|
|
||||||
intervalMs: number;
|
|
||||||
questArchiveDays: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial default config state
|
// Initial default config state
|
||||||
@@ -167,17 +162,7 @@ const configSchema = z.object({
|
|||||||
dmOnWarn: true
|
dmOnWarn: true
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
system: z.object({
|
system: z.record(z.string(), z.any()).default({}),
|
||||||
cleanup: z.object({
|
|
||||||
intervalMs: z.number().default(24 * 60 * 60 * 1000), // Daily
|
|
||||||
questArchiveDays: z.number().default(30)
|
|
||||||
})
|
|
||||||
}).default({
|
|
||||||
cleanup: {
|
|
||||||
intervalMs: 24 * 60 * 60 * 1000,
|
|
||||||
questArchiveDays: 30
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function reloadConfig() {
|
export function reloadConfig() {
|
||||||
|
|||||||
65
src/lib/constants.ts
Normal file
65
src/lib/constants.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Global Constants and Enums
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum TimerType {
|
||||||
|
COOLDOWN = 'COOLDOWN',
|
||||||
|
EFFECT = 'EFFECT',
|
||||||
|
ACCESS = 'ACCESS',
|
||||||
|
EXAM_SYSTEM = 'EXAM_SYSTEM',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EffectType {
|
||||||
|
ADD_XP = 'ADD_XP',
|
||||||
|
ADD_BALANCE = 'ADD_BALANCE',
|
||||||
|
REPLY_MESSAGE = 'REPLY_MESSAGE',
|
||||||
|
XP_BOOST = 'XP_BOOST',
|
||||||
|
TEMP_ROLE = 'TEMP_ROLE',
|
||||||
|
COLOR_ROLE = 'COLOR_ROLE',
|
||||||
|
LOOTBOX = 'LOOTBOX',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionType {
|
||||||
|
TRANSFER_IN = 'TRANSFER_IN',
|
||||||
|
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||||
|
DAILY_REWARD = 'DAILY_REWARD',
|
||||||
|
ITEM_USE = 'ITEM_USE',
|
||||||
|
LOOTBOX = 'LOOTBOX',
|
||||||
|
EXAM_REWARD = 'EXAM_REWARD',
|
||||||
|
PURCHASE = 'PURCHASE',
|
||||||
|
TRADE_IN = 'TRADE_IN',
|
||||||
|
TRADE_OUT = 'TRADE_OUT',
|
||||||
|
QUEST_REWARD = 'QUEST_REWARD',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemTransactionType {
|
||||||
|
TRADE_IN = 'TRADE_IN',
|
||||||
|
TRADE_OUT = 'TRADE_OUT',
|
||||||
|
SHOP_BUY = 'SHOP_BUY',
|
||||||
|
DROP = 'DROP',
|
||||||
|
GIVE = 'GIVE',
|
||||||
|
USE = 'USE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ItemType {
|
||||||
|
MATERIAL = 'MATERIAL',
|
||||||
|
CONSUMABLE = 'CONSUMABLE',
|
||||||
|
EQUIPMENT = 'EQUIPMENT',
|
||||||
|
QUEST = 'QUEST',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CaseType {
|
||||||
|
WARN = 'warn',
|
||||||
|
TIMEOUT = 'timeout',
|
||||||
|
KICK = 'kick',
|
||||||
|
BAN = 'ban',
|
||||||
|
NOTE = 'note',
|
||||||
|
PRUNE = 'prune',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LootType {
|
||||||
|
NOTHING = 'NOTHING',
|
||||||
|
CURRENCY = 'CURRENCY',
|
||||||
|
XP = 'XP',
|
||||||
|
ITEM = 'ITEM',
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const envSchema = z.object({
|
|||||||
DISCORD_CLIENT_ID: z.string().optional(),
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
DISCORD_GUILD_ID: z.string().optional(),
|
DISCORD_GUILD_ID: z.string().optional(),
|
||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedEnv = envSchema.safeParse(process.env);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class CommandLoader {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue;
|
if ((!file.name.endsWith('.ts') && !file.name.endsWith('.js')) || file.name.endsWith('.test.ts') || file.name.endsWith('.spec.ts')) continue;
|
||||||
|
|
||||||
await this.loadCommandFile(filePath, reload, result);
|
await this.loadCommandFile(filePath, reload, result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
import type { AutocompleteInteraction, ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
|
||||||
|
import { LootType, EffectType } from "./constants";
|
||||||
|
import { DrizzleClient } from "./DrizzleClient";
|
||||||
|
|
||||||
export interface Command {
|
export interface Command {
|
||||||
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||||
@@ -14,16 +16,16 @@ export interface Event<K extends keyof ClientEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ItemEffect =
|
export type ItemEffect =
|
||||||
| { type: 'ADD_XP'; amount: number }
|
| { type: EffectType.ADD_XP; amount: number }
|
||||||
| { type: 'ADD_BALANCE'; amount: number }
|
| { type: EffectType.ADD_BALANCE; amount: number }
|
||||||
| { type: 'XP_BOOST'; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
| { type: EffectType.XP_BOOST; multiplier: number; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||||
| { type: 'TEMP_ROLE'; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
| { type: EffectType.TEMP_ROLE; roleId: string; durationSeconds?: number; durationMinutes?: number; durationHours?: number }
|
||||||
| { type: 'REPLY_MESSAGE'; message: string }
|
| { type: EffectType.REPLY_MESSAGE; message: string }
|
||||||
| { type: 'COLOR_ROLE'; roleId: string }
|
| { type: EffectType.COLOR_ROLE; roleId: string }
|
||||||
| { type: 'LOOTBOX'; pool: LootTableItem[] };
|
| { type: EffectType.LOOTBOX; pool: LootTableItem[] };
|
||||||
|
|
||||||
export interface LootTableItem {
|
export interface LootTableItem {
|
||||||
type: 'CURRENCY' | 'ITEM' | 'XP' | 'NOTHING';
|
type: LootType;
|
||||||
weight: number;
|
weight: number;
|
||||||
amount?: number; // For CURRENCY, XP
|
amount?: number; // For CURRENCY, XP
|
||||||
itemId?: number; // For ITEM
|
itemId?: number; // For ITEM
|
||||||
@@ -37,7 +39,5 @@ export interface ItemUsageData {
|
|||||||
effects: ItemEffect[];
|
effects: ItemEffect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { DrizzleClient } from "./DrizzleClient";
|
|
||||||
|
|
||||||
export type DbClient = typeof DrizzleClient;
|
export type DbClient = typeof DrizzleClient;
|
||||||
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];
|
export type Transaction = Parameters<Parameters<DbClient['transaction']>[0]>[0];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
|
|||||||
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
import type { ItemUsageData, ItemEffect } from "@/lib/types";
|
||||||
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType, EffectType } from "@/lib/constants";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export const renderWizard = (userId: string, isDraft = true) => {
|
|||||||
name: "New Item",
|
name: "New Item",
|
||||||
description: "No description",
|
description: "No description",
|
||||||
rarity: "Common",
|
rarity: "Common",
|
||||||
type: "MATERIAL",
|
type: ItemType.MATERIAL,
|
||||||
price: null,
|
price: null,
|
||||||
iconUrl: "",
|
iconUrl: "",
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
@@ -176,26 +177,26 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
if (type) {
|
if (type) {
|
||||||
let effect: ItemEffect | null = null;
|
let effect: ItemEffect | null = null;
|
||||||
|
|
||||||
if (type === "ADD_XP" || type === "ADD_BALANCE") {
|
if (type === EffectType.ADD_XP || type === EffectType.ADD_BALANCE) {
|
||||||
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
const amount = parseInt(interaction.fields.getTextInputValue("amount"));
|
||||||
if (!isNaN(amount)) effect = { type: type as any, amount };
|
if (!isNaN(amount)) effect = { type: type as any, amount };
|
||||||
}
|
}
|
||||||
else if (type === "REPLY_MESSAGE") {
|
else if (type === EffectType.REPLY_MESSAGE) {
|
||||||
effect = { type: "REPLY_MESSAGE", message: interaction.fields.getTextInputValue("message") };
|
effect = { type: EffectType.REPLY_MESSAGE, message: interaction.fields.getTextInputValue("message") };
|
||||||
}
|
}
|
||||||
else if (type === "XP_BOOST") {
|
else if (type === EffectType.XP_BOOST) {
|
||||||
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
const multiplier = parseFloat(interaction.fields.getTextInputValue("multiplier"));
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: "XP_BOOST", multiplier, durationSeconds: duration };
|
if (!isNaN(multiplier) && !isNaN(duration)) effect = { type: EffectType.XP_BOOST, multiplier, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "TEMP_ROLE") {
|
else if (type === EffectType.TEMP_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
const duration = parseInt(interaction.fields.getTextInputValue("duration"));
|
||||||
if (roleId && !isNaN(duration)) effect = { type: "TEMP_ROLE", roleId: roleId, durationSeconds: duration };
|
if (roleId && !isNaN(duration)) effect = { type: EffectType.TEMP_ROLE, roleId: roleId, durationSeconds: duration };
|
||||||
}
|
}
|
||||||
else if (type === "COLOR_ROLE") {
|
else if (type === EffectType.COLOR_ROLE) {
|
||||||
const roleId = interaction.fields.getTextInputValue("role_id");
|
const roleId = interaction.fields.getTextInputValue("role_id");
|
||||||
if (roleId) effect = { type: "COLOR_ROLE", roleId: roleId };
|
if (roleId) effect = { type: EffectType.COLOR_ROLE, roleId: roleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effect) {
|
if (effect) {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { createBaseEmbed } from "@lib/embeds";
|
import { createBaseEmbed } from "@lib/embeds";
|
||||||
import type { DraftItem } from "./item_wizard.types";
|
import type { DraftItem } from "./item_wizard.types";
|
||||||
|
import { ItemType } from "@/lib/constants";
|
||||||
|
|
||||||
const getItemTypeOptions = () => [
|
const getItemTypeOptions = () => [
|
||||||
{ label: "Material", value: "MATERIAL", description: "Used for crafting or trading" },
|
{ label: "Material", value: ItemType.MATERIAL, description: "Used for crafting or trading" },
|
||||||
{ label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" },
|
{ label: "Consumable", value: ItemType.CONSUMABLE, description: "Can be used to gain effects" },
|
||||||
{ label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" },
|
{ label: "Equipment", value: ItemType.EQUIPMENT, description: "Can be equipped (Not yet implemented)" },
|
||||||
{ label: "Quest Item", value: "QUEST", description: "Required for quests" },
|
{ label: "Quest Item", value: ItemType.QUEST, description: "Required for quests" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getEffectTypeOptions = () => [
|
const getEffectTypeOptions = () => [
|
||||||
|
|||||||
@@ -209,6 +209,15 @@ describe("economyService", () => {
|
|||||||
expect(result.streak).toBe(1);
|
expect(result.streak).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve streak if cooldown is missing but user has a streak", async () => {
|
||||||
|
mockFindFirst
|
||||||
|
.mockResolvedValueOnce(undefined) // No cooldown
|
||||||
|
.mockResolvedValueOnce({ id: 1n, dailyStreak: 10 });
|
||||||
|
|
||||||
|
const result = await economyService.claimDaily("1");
|
||||||
|
expect(result.streak).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
it("should prevent weekly bonus exploit by resetting streak", async () => {
|
it("should prevent weekly bonus exploit by resetting streak", async () => {
|
||||||
// Mock user at streak 7.
|
// Mock user at streak 7.
|
||||||
// Mock time as 24h + 1m after expiry.
|
// Mock time as 24h + 1m after expiry.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { config } from "@/lib/config";
|
|||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
|
import { TimerType, TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
export const economyService = {
|
export const economyService = {
|
||||||
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
|
transfer: async (fromUserId: string, toUserId: string, amount: bigint, tx?: Transaction) => {
|
||||||
@@ -48,7 +49,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(fromUserId),
|
userId: BigInt(fromUserId),
|
||||||
amount: -amount,
|
amount: -amount,
|
||||||
type: 'TRANSFER_OUT',
|
type: TransactionType.TRANSFER_OUT,
|
||||||
description: `Transfer to ${toUserId}`,
|
description: `Transfer to ${toUserId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(toUserId),
|
userId: BigInt(toUserId),
|
||||||
amount: amount,
|
amount: amount,
|
||||||
type: 'TRANSFER_IN',
|
type: TransactionType.TRANSFER_IN,
|
||||||
description: `Transfer from ${fromUserId}`,
|
description: `Transfer from ${fromUserId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,14 +68,12 @@ export const economyService = {
|
|||||||
claimDaily: async (userId: string, tx?: Transaction) => {
|
claimDaily: async (userId: string, tx?: Transaction) => {
|
||||||
return await withTransaction(async (txFn) => {
|
return await withTransaction(async (txFn) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startOfDay = new Date(now);
|
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Check cooldown
|
// Check cooldown
|
||||||
const cooldown = await txFn.query.userTimers.findFirst({
|
const cooldown = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(userId)),
|
eq(userTimers.userId, BigInt(userId)),
|
||||||
eq(userTimers.type, 'COOLDOWN'),
|
eq(userTimers.type, TimerType.COOLDOWN),
|
||||||
eq(userTimers.key, 'daily')
|
eq(userTimers.key, 'daily')
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -89,17 +88,23 @@ export const economyService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("User not found"); // This might be system error because user should exist if authenticated, but keeping simple for now
|
throw new Error("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
let streak = (user.dailyStreak || 0) + 1;
|
let streak = (user.dailyStreak || 0) + 1;
|
||||||
|
|
||||||
// If previous cooldown exists and expired more than 24h ago (meaning >48h since last claim), reduce streak by one for each day passed minimum 1
|
// Check if streak should be reset due to missing a day
|
||||||
if (cooldown) {
|
if (cooldown) {
|
||||||
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
|
const timeSinceReady = now.getTime() - cooldown.expiresAt.getTime();
|
||||||
|
// If more than 24h passed since it became ready, they missed a full calendar day
|
||||||
if (timeSinceReady > 24 * 60 * 60 * 1000) {
|
if (timeSinceReady > 24 * 60 * 60 * 1000) {
|
||||||
streak = 1;
|
streak = 1;
|
||||||
}
|
}
|
||||||
|
} else if ((user.dailyStreak || 0) > 0) {
|
||||||
|
// If no cooldown record exists but user has a streak,
|
||||||
|
// we'll allow one "free" increment to restore the timer state.
|
||||||
|
// This prevents unfair resets if timers were cleared/lost.
|
||||||
|
streak = (user.dailyStreak || 0) + 1;
|
||||||
} else {
|
} else {
|
||||||
streak = 1;
|
streak = 1;
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,7 @@ export const economyService = {
|
|||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'COOLDOWN',
|
type: TimerType.COOLDOWN,
|
||||||
key: 'daily',
|
key: 'daily',
|
||||||
expiresAt: nextReadyAt,
|
expiresAt: nextReadyAt,
|
||||||
})
|
})
|
||||||
@@ -140,7 +145,7 @@ export const economyService = {
|
|||||||
await txFn.insert(transactions).values({
|
await txFn.insert(transactions).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
amount: totalReward,
|
amount: totalReward,
|
||||||
type: 'DAILY_REWARD',
|
type: TransactionType.DAILY_REWARD,
|
||||||
description: `Daily reward (Streak: ${streak})`,
|
description: `Daily reward (Streak: ${streak})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class LootdropService {
|
|||||||
// Cleanup interval for activity tracking and expired lootdrops
|
// Cleanup interval for activity tracking and expired lootdrops
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.cleanupActivity();
|
this.cleanupActivity();
|
||||||
this.cleanupExpiredLootdrops();
|
this.cleanupExpiredLootdrops(true);
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +45,24 @@ class LootdropService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupExpiredLootdrops() {
|
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await DrizzleClient.delete(lootdrops)
|
const whereClause = includeClaimed
|
||||||
.where(and(
|
? lt(lootdrops.expiresAt, now)
|
||||||
isNull(lootdrops.claimedBy),
|
: and(isNull(lootdrops.claimedBy), lt(lootdrops.expiresAt, now));
|
||||||
lt(lootdrops.expiresAt, now)
|
|
||||||
));
|
const result = await DrizzleClient.delete(lootdrops)
|
||||||
|
.where(whereClause)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
console.log(`[LootdropService] Cleaned up ${result.length} expired lootdrops.`);
|
||||||
|
}
|
||||||
|
return result.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to cleanup lootdrops:", error);
|
console.error("Failed to cleanup lootdrops:", error);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { EffectHandler } from "./types";
|
|||||||
import type { LootTableItem } from "@/lib/types";
|
import type { LootTableItem } from "@/lib/types";
|
||||||
import { inventoryService } from "@/modules/inventory/inventory.service";
|
import { inventoryService } from "@/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@/db/schema";
|
import { inventory, items } from "@/db/schema";
|
||||||
|
import { TimerType, TransactionType, LootType } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
// Helper to extract duration in seconds
|
// Helper to extract duration in seconds
|
||||||
@@ -20,7 +21,7 @@ export const handleAddXp: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(effect.amount), 'ITEM_USE', `Used Item`, null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
return `Gained ${effect.amount} 🪙`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'EFFECT',
|
type: TimerType.EFFECT,
|
||||||
key: 'xp_boost',
|
key: 'xp_boost',
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
metadata: { multiplier: effect.multiplier }
|
metadata: { multiplier: effect.multiplier }
|
||||||
@@ -49,7 +50,7 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
await txFn.insert(userTimers).values({
|
||||||
userId: BigInt(userId),
|
userId: BigInt(userId),
|
||||||
type: 'ACCESS',
|
type: TimerType.ACCESS,
|
||||||
key: `role_${effect.roleId}`,
|
key: `role_${effect.roleId}`,
|
||||||
expiresAt: roleExpiresAt,
|
expiresAt: roleExpiresAt,
|
||||||
metadata: { roleId: effect.roleId }
|
metadata: { roleId: effect.roleId }
|
||||||
@@ -84,22 +85,22 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
if (!winner) return "The box is empty..."; // Should not happen
|
if (!winner) return "The box is empty..."; // Should not happen
|
||||||
|
|
||||||
// Process Winner
|
// Process Winner
|
||||||
if (winner.type === 'NOTHING') {
|
if (winner.type === LootType.NOTHING) {
|
||||||
return winner.message || "You found nothing inside.";
|
return winner.message || "You found nothing inside.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (winner.type === 'CURRENCY') {
|
if (winner.type === LootType.CURRENCY) {
|
||||||
let amount = winner.amount || 0;
|
let amount = winner.amount || 0;
|
||||||
if (winner.minAmount && winner.maxAmount) {
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
}
|
}
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
await economyService.modifyUserBalance(userId, BigInt(amount), 'LOOTBOX', 'Lootbox Reward', null, txFn);
|
await economyService.modifyUserBalance(userId, BigInt(amount), TransactionType.LOOTBOX, 'Lootbox Reward', null, txFn);
|
||||||
return winner.message || `You found ${amount} 🪙!`;
|
return winner.message || `You found ${amount} 🪙!`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (winner.type === 'XP') {
|
if (winner.type === LootType.XP) {
|
||||||
let amount = winner.amount || 0;
|
let amount = winner.amount || 0;
|
||||||
if (winner.minAmount && winner.maxAmount) {
|
if (winner.minAmount && winner.maxAmount) {
|
||||||
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
amount = Math.floor(Math.random() * (winner.maxAmount - winner.minAmount + 1)) + winner.minAmount;
|
||||||
@@ -110,7 +111,7 @@ export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (winner.type === 'ITEM') {
|
if (winner.type === LootType.ITEM) {
|
||||||
if (winner.itemId) {
|
if (winner.itemId) {
|
||||||
const quantity = BigInt(winner.amount || 1);
|
const quantity = BigInt(winner.amount || 1);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const mockWhere = mock();
|
|||||||
const mockSelect = mock();
|
const mockSelect = mock();
|
||||||
const mockFrom = mock();
|
const mockFrom = mock();
|
||||||
const mockOnConflictDoUpdate = mock();
|
const mockOnConflictDoUpdate = mock();
|
||||||
|
const mockInnerJoin = mock();
|
||||||
|
const mockLimit = mock();
|
||||||
|
|
||||||
// Chain setup
|
// Chain setup
|
||||||
mockInsert.mockReturnValue({ values: mockValues });
|
mockInsert.mockReturnValue({ values: mockValues });
|
||||||
@@ -34,7 +36,10 @@ mockWhere.mockReturnValue({ returning: mockReturning });
|
|||||||
mockDelete.mockReturnValue({ where: mockWhere });
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
mockSelect.mockReturnValue({ from: mockFrom });
|
mockSelect.mockReturnValue({ from: mockFrom });
|
||||||
mockFrom.mockReturnValue({ where: mockWhere });
|
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
|
||||||
|
mockInnerJoin.mockReturnValue({ where: mockWhere });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||||
|
mockLimit.mockResolvedValue([]);
|
||||||
|
|
||||||
// Mock DrizzleClient
|
// Mock DrizzleClient
|
||||||
mock.module("@/lib/DrizzleClient", () => {
|
mock.module("@/lib/DrizzleClient", () => {
|
||||||
@@ -239,4 +244,39 @@ describe("inventoryService", () => {
|
|||||||
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
expect(mockDelete).toHaveBeenCalledWith(inventory); // Consume
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getAutocompleteItems", () => {
|
||||||
|
it("should return formatted autocomplete results with rarity", async () => {
|
||||||
|
const mockItems = [
|
||||||
|
{
|
||||||
|
item: { id: 1, name: "Common Sword", rarity: "Common", usageData: { effects: [{}] } },
|
||||||
|
quantity: 5n
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item: { id: 2, name: "Epic Shield", rarity: "Epic", usageData: { effects: [{}] } },
|
||||||
|
quantity: 1n
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
mockLimit.mockResolvedValue(mockItems);
|
||||||
|
|
||||||
|
// Restore mocks that might have been polluted by other tests
|
||||||
|
mockFrom.mockReturnValue({ where: mockWhere, innerJoin: mockInnerJoin });
|
||||||
|
mockWhere.mockReturnValue({ returning: mockReturning, limit: mockLimit });
|
||||||
|
|
||||||
|
const result = await inventoryService.getAutocompleteItems("1", "Sw");
|
||||||
|
|
||||||
|
expect(mockSelect).toHaveBeenCalled();
|
||||||
|
expect(mockFrom).toHaveBeenCalledWith(inventory);
|
||||||
|
expect(mockInnerJoin).toHaveBeenCalled(); // checks join
|
||||||
|
expect(mockWhere).toHaveBeenCalled(); // checks filters
|
||||||
|
expect(mockLimit).toHaveBeenCalledWith(20);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]?.name).toBe("Common Sword (5) [Common]");
|
||||||
|
expect(result[0]?.value).toBe(1);
|
||||||
|
expect(result[1]?.name).toBe("Epic Shield (1) [Epic]");
|
||||||
|
expect(result[1]?.value).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { inventory, items, users, userTimers } from "@/db/schema";
|
import { inventory, items, users, userTimers } from "@/db/schema";
|
||||||
import { eq, and, sql, count } from "drizzle-orm";
|
import { eq, and, sql, count, ilike } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { economyService } from "@/modules/economy/economy.service";
|
import { economyService } from "@/modules/economy/economy.service";
|
||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
@@ -7,6 +7,7 @@ import { config } from "@/lib/config";
|
|||||||
import { UserError } from "@/lib/errors";
|
import { UserError } from "@/lib/errors";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction, ItemUsageData } from "@/lib/types";
|
import type { Transaction, ItemUsageData } from "@/lib/types";
|
||||||
|
import { TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export const inventoryService = {
|
|||||||
const totalPrice = item.price * quantity;
|
const totalPrice = item.price * quantity;
|
||||||
|
|
||||||
// Deduct Balance using economy service (passing tx ensures atomicity)
|
// Deduct Balance using economy service (passing tx ensures atomicity)
|
||||||
await economyService.modifyUserBalance(userId, -totalPrice, 'PURCHASE', `Bought ${quantity}x ${item.name}`, null, txFn);
|
await economyService.modifyUserBalance(userId, -totalPrice, TransactionType.PURCHASE, `Bought ${quantity}x ${item.name}`, null, txFn);
|
||||||
|
|
||||||
await inventoryService.addItem(userId, itemId, quantity, txFn);
|
await inventoryService.addItem(userId, itemId, quantity, txFn);
|
||||||
|
|
||||||
@@ -180,5 +181,29 @@ export const inventoryService = {
|
|||||||
|
|
||||||
return { success: true, results, usageData, item };
|
return { success: true, results, usageData, item };
|
||||||
}, tx);
|
}, tx);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAutocompleteItems: async (userId: string, query: string) => {
|
||||||
|
const entries = await DrizzleClient.select({
|
||||||
|
quantity: inventory.quantity,
|
||||||
|
item: items
|
||||||
|
})
|
||||||
|
.from(inventory)
|
||||||
|
.innerJoin(items, eq(inventory.itemId, items.id))
|
||||||
|
.where(and(
|
||||||
|
eq(inventory.userId, BigInt(userId)),
|
||||||
|
ilike(items.name, `%${query}%`)
|
||||||
|
))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
const filtered = entries.filter(entry => {
|
||||||
|
const usageData = entry.item.usageData as ItemUsageData | null;
|
||||||
|
return usageData && usageData.effects && usageData.effects.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered.map(entry => ({
|
||||||
|
name: `${entry.item.name} (${entry.quantity}) [${entry.item.rarity || 'Common'}]`,
|
||||||
|
value: entry.item.id
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
import type { ItemUsageData } from "@/lib/types";
|
import type { ItemUsageData } from "@/lib/types";
|
||||||
|
import { EffectType } from "@/lib/constants";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory entry with item details
|
* Inventory entry with item details
|
||||||
@@ -34,7 +35,7 @@ export function getItemUseResultEmbed(results: string[], item?: { name: string,
|
|||||||
const description = results.map(r => `• ${r}`).join("\n");
|
const description = results.map(r => `• ${r}`).join("\n");
|
||||||
|
|
||||||
// Check if it was a lootbox
|
// Check if it was a lootbox
|
||||||
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === 'LOOTBOX');
|
const isLootbox = item?.usageData?.effects?.some((e: any) => e.type === EffectType.LOOTBOX);
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setDescription(description)
|
.setDescription(description)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq, sql, and } from "drizzle-orm";
|
|||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
export const levelingService = {
|
export const levelingService = {
|
||||||
// Calculate total XP required to REACH a specific level (Cumulative)
|
// Calculate total XP required to REACH a specific level (Cumulative)
|
||||||
@@ -78,7 +79,7 @@ export const levelingService = {
|
|||||||
const cooldown = await txFn.query.userTimers.findFirst({
|
const cooldown = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(id)),
|
eq(userTimers.userId, BigInt(id)),
|
||||||
eq(userTimers.type, 'COOLDOWN'),
|
eq(userTimers.type, TimerType.COOLDOWN),
|
||||||
eq(userTimers.key, 'chat_xp')
|
eq(userTimers.key, 'chat_xp')
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -95,7 +96,7 @@ export const levelingService = {
|
|||||||
const xpBoost = await txFn.query.userTimers.findFirst({
|
const xpBoost = await txFn.query.userTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(userTimers.userId, BigInt(id)),
|
eq(userTimers.userId, BigInt(id)),
|
||||||
eq(userTimers.type, 'EFFECT'),
|
eq(userTimers.type, TimerType.EFFECT),
|
||||||
eq(userTimers.key, 'xp_boost')
|
eq(userTimers.key, 'xp_boost')
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
@@ -114,7 +115,7 @@ export const levelingService = {
|
|||||||
await txFn.insert(userTimers)
|
await txFn.insert(userTimers)
|
||||||
.values({
|
.values({
|
||||||
userId: BigInt(id),
|
userId: BigInt(id),
|
||||||
type: 'COOLDOWN',
|
type: TimerType.COOLDOWN,
|
||||||
key: 'chat_xp',
|
key: 'chat_xp',
|
||||||
expiresAt: nextReadyAt,
|
expiresAt: nextReadyAt,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
import { ModerationService } from "./moderation.service";
|
import { ModerationService } from "./moderation.service";
|
||||||
import { moderationCases } from "@/db/schema";
|
import { moderationCases } from "@/db/schema";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
|
|
||||||
// Mock Drizzle Functions
|
// Mock Drizzle Functions
|
||||||
const mockFindFirst = mock();
|
const mockFindFirst = mock();
|
||||||
@@ -83,7 +84,7 @@ describe("ModerationService", () => {
|
|||||||
it("should issue a warning and attempt to DM the user", async () => {
|
it("should issue a warning and attempt to DM the user", async () => {
|
||||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
||||||
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
|
mockReturning.mockResolvedValue([{ caseId: "CASE-0002" }]);
|
||||||
mockFindMany.mockResolvedValue([{ type: 'warn', active: true }]); // 1 warning total
|
mockFindMany.mockResolvedValue([{ type: CaseType.WARN, active: true }]); // 1 warning total
|
||||||
|
|
||||||
const mockDmTarget = { send: mock() };
|
const mockDmTarget = { send: mock() };
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ describe("ModerationService", () => {
|
|||||||
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
mockFindFirst.mockResolvedValue({ caseId: "CASE-0001" });
|
||||||
const mockNewCase = {
|
const mockNewCase = {
|
||||||
caseId: "CASE-0002",
|
caseId: "CASE-0002",
|
||||||
type: 'warn',
|
type: CaseType.WARN,
|
||||||
userId: 123456789n,
|
userId: 123456789n,
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
moderatorId: 987654321n,
|
moderatorId: 987654321n,
|
||||||
@@ -190,7 +191,7 @@ describe("ModerationService", () => {
|
|||||||
mockReturning.mockResolvedValue([mockNewCase]);
|
mockReturning.mockResolvedValue([mockNewCase]);
|
||||||
|
|
||||||
const result = await ModerationService.createCase({
|
const result = await ModerationService.createCase({
|
||||||
type: 'warn',
|
type: CaseType.WARN,
|
||||||
userId: "123456789",
|
userId: "123456789",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
moderatorId: "987654321",
|
moderatorId: "987654321",
|
||||||
@@ -202,7 +203,7 @@ describe("ModerationService", () => {
|
|||||||
expect(mockInsert).toHaveBeenCalled();
|
expect(mockInsert).toHaveBeenCalled();
|
||||||
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
caseId: "CASE-0002",
|
caseId: "CASE-0002",
|
||||||
type: 'warn',
|
type: CaseType.WARN,
|
||||||
userId: 123456789n,
|
userId: 123456789n,
|
||||||
reason: "test reason"
|
reason: "test reason"
|
||||||
}));
|
}));
|
||||||
@@ -213,7 +214,7 @@ describe("ModerationService", () => {
|
|||||||
mockReturning.mockImplementation((values) => [values]); // Simplified mock
|
mockReturning.mockImplementation((values) => [values]); // Simplified mock
|
||||||
|
|
||||||
const result = await ModerationService.createCase({
|
const result = await ModerationService.createCase({
|
||||||
type: 'ban',
|
type: CaseType.BAN,
|
||||||
userId: "123456789",
|
userId: "123456789",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
moderatorId: "987654321",
|
moderatorId: "987654321",
|
||||||
@@ -273,8 +274,8 @@ describe("ModerationService", () => {
|
|||||||
describe("getActiveWarningCount", () => {
|
describe("getActiveWarningCount", () => {
|
||||||
it("should return the number of active warnings", async () => {
|
it("should return the number of active warnings", async () => {
|
||||||
mockFindMany.mockResolvedValue([
|
mockFindMany.mockResolvedValue([
|
||||||
{ id: 1n, type: 'warn', active: true },
|
{ id: 1n, type: CaseType.WARN, active: true },
|
||||||
{ id: 2n, type: 'warn', active: true }
|
{ id: 2n, type: CaseType.WARN, active: true }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const count = await ModerationService.getActiveWarningCount("123456789");
|
const count = await ModerationService.getActiveWarningCount("123456789");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DrizzleClient } from "@/lib/DrizzleClient";
|
|||||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
|
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "./moderation.types";
|
||||||
import { config } from "@/lib/config";
|
import { config } from "@/lib/config";
|
||||||
import { getUserWarningEmbed } from "./moderation.view";
|
import { getUserWarningEmbed } from "./moderation.view";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +44,7 @@ export class ModerationService {
|
|||||||
moderatorName: options.moderatorName,
|
moderatorName: options.moderatorName,
|
||||||
reason: options.reason,
|
reason: options.reason,
|
||||||
metadata: options.metadata || {},
|
metadata: options.metadata || {},
|
||||||
active: options.type === 'warn' ? true : false, // Only warnings are "active" by default
|
active: options.type === CaseType.WARN ? true : false, // Only warnings are "active" by default
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
return newCase;
|
return newCase;
|
||||||
@@ -63,7 +64,7 @@ export class ModerationService {
|
|||||||
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
||||||
}) {
|
}) {
|
||||||
const moderationCase = await this.createCase({
|
const moderationCase = await this.createCase({
|
||||||
type: 'warn',
|
type: CaseType.WARN,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
username: options.username,
|
username: options.username,
|
||||||
moderatorId: options.moderatorId,
|
moderatorId: options.moderatorId,
|
||||||
@@ -105,7 +106,7 @@ export class ModerationService {
|
|||||||
|
|
||||||
// Create a timeout case
|
// Create a timeout case
|
||||||
await this.createCase({
|
await this.createCase({
|
||||||
type: 'timeout',
|
type: CaseType.TIMEOUT,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
username: options.username,
|
username: options.username,
|
||||||
moderatorId: "0", // System/Bot
|
moderatorId: "0", // System/Bot
|
||||||
@@ -154,7 +155,7 @@ export class ModerationService {
|
|||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
eq(moderationCases.type, 'warn'),
|
eq(moderationCases.type, CaseType.WARN),
|
||||||
eq(moderationCases.active, true)
|
eq(moderationCases.active, true)
|
||||||
),
|
),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
@@ -168,7 +169,7 @@ export class ModerationService {
|
|||||||
return await DrizzleClient.query.moderationCases.findMany({
|
return await DrizzleClient.query.moderationCases.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(moderationCases.userId, BigInt(userId)),
|
eq(moderationCases.userId, BigInt(userId)),
|
||||||
eq(moderationCases.type, 'note')
|
eq(moderationCases.type, CaseType.NOTE)
|
||||||
),
|
),
|
||||||
orderBy: [desc(moderationCases.createdAt)],
|
orderBy: [desc(moderationCases.createdAt)],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type CaseType = 'warn' | 'timeout' | 'kick' | 'ban' | 'note' | 'prune';
|
import { CaseType } from "@/lib/constants";
|
||||||
|
|
||||||
|
export { CaseType };
|
||||||
|
|
||||||
export interface CreateCaseOptions {
|
export interface CreateCaseOptions {
|
||||||
type: CaseType;
|
type: CaseType;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { economyService } from "@/modules/economy/economy.service";
|
|||||||
import { levelingService } from "@/modules/leveling/leveling.service";
|
import { levelingService } from "@/modules/leveling/leveling.service";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
export const questService = {
|
export const questService = {
|
||||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||||
@@ -62,7 +63,7 @@ export const questService = {
|
|||||||
|
|
||||||
if (rewards?.balance) {
|
if (rewards?.balance) {
|
||||||
const bal = BigInt(rewards.balance);
|
const bal = BigInt(rewards.balance);
|
||||||
await economyService.modifyUserBalance(userId, bal, 'QUEST_REWARD', `Reward for quest ${questId}`, null, txFn);
|
await economyService.modifyUserBalance(userId, bal, TransactionType.QUEST_REWARD, `Reward for quest ${questId}`, null, txFn);
|
||||||
results.balance = bal;
|
results.balance = bal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
|
|
||||||
import { cleanupService } from "./cleanup.service";
|
|
||||||
import { lootdrops, userTimers, userQuests } from "@/db/schema";
|
|
||||||
import { config } from "@/lib/config";
|
|
||||||
|
|
||||||
const mockDelete = mock();
|
|
||||||
const mockReturning = mock();
|
|
||||||
const mockWhere = mock();
|
|
||||||
const mockFindMany = mock();
|
|
||||||
|
|
||||||
mockDelete.mockReturnValue({ where: mockWhere });
|
|
||||||
mockWhere.mockReturnValue({ returning: mockReturning });
|
|
||||||
|
|
||||||
// Mock DrizzleClient
|
|
||||||
mock.module("@/lib/DrizzleClient", () => ({
|
|
||||||
DrizzleClient: {
|
|
||||||
delete: mockDelete,
|
|
||||||
query: {
|
|
||||||
userTimers: {
|
|
||||||
findMany: mockFindMany
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock AuroraClient
|
|
||||||
mock.module("@/lib/BotClient", () => ({
|
|
||||||
AuroraClient: {
|
|
||||||
guilds: {
|
|
||||||
fetch: mock().mockResolvedValue({
|
|
||||||
members: {
|
|
||||||
fetch: mock().mockResolvedValue({
|
|
||||||
user: { tag: "TestUser#1234" },
|
|
||||||
roles: { remove: mock().mockResolvedValue({}) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Config
|
|
||||||
mock.module("@/lib/config", () => ({
|
|
||||||
config: {
|
|
||||||
system: {
|
|
||||||
cleanup: {
|
|
||||||
intervalMs: 86400000,
|
|
||||||
questArchiveDays: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("cleanupService", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDelete.mockClear();
|
|
||||||
mockWhere.mockClear();
|
|
||||||
mockReturning.mockClear();
|
|
||||||
mockFindMany.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cleanupLootdrops should delete expired unclaimed lootdrops", async () => {
|
|
||||||
mockReturning.mockResolvedValue([{ id: "msg1" }, { id: "msg2" }]);
|
|
||||||
|
|
||||||
const count = await cleanupService.cleanupLootdrops();
|
|
||||||
|
|
||||||
expect(count).toBe(2);
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith(lootdrops);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cleanupTimers should delete expired timers and handle roles", async () => {
|
|
||||||
// Mock findMany for expired ACCESS timers
|
|
||||||
mockFindMany.mockResolvedValue([
|
|
||||||
{ userId: 123n, type: 'ACCESS', key: 'role_456', metadata: { roleId: '456' } }
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Mock returning for bulk delete
|
|
||||||
mockReturning.mockResolvedValue([{ userId: 789n }]); // One other timer
|
|
||||||
|
|
||||||
const count = await cleanupService.cleanupTimers();
|
|
||||||
|
|
||||||
// 1 from findMany + 1 from bulk delete (simplified mock behavior)
|
|
||||||
expect(count).toBe(2);
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith(userTimers);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cleanupQuests should delete old completed quests", async () => {
|
|
||||||
mockReturning.mockResolvedValue([{ userId: 123n }]);
|
|
||||||
|
|
||||||
const count = await cleanupService.cleanupQuests();
|
|
||||||
|
|
||||||
expect(count).toBe(1);
|
|
||||||
expect(mockDelete).toHaveBeenCalledWith(userQuests);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runAll should run all cleanup tasks and log stats", async () => {
|
|
||||||
const spyLootdrops = spyOn(cleanupService, 'cleanupLootdrops').mockResolvedValue(1);
|
|
||||||
const spyTimers = spyOn(cleanupService, 'cleanupTimers').mockResolvedValue(2);
|
|
||||||
const spyQuests = spyOn(cleanupService, 'cleanupQuests').mockResolvedValue(3);
|
|
||||||
|
|
||||||
await cleanupService.runAll();
|
|
||||||
|
|
||||||
expect(spyLootdrops).toHaveBeenCalled();
|
|
||||||
expect(spyTimers).toHaveBeenCalled();
|
|
||||||
expect(spyQuests).toHaveBeenCalled();
|
|
||||||
|
|
||||||
spyLootdrops.mockRestore();
|
|
||||||
spyTimers.mockRestore();
|
|
||||||
spyQuests.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { lootdrops, userTimers, userQuests } from "@/db/schema";
|
|
||||||
import { eq, and, lt, isNull, sql } from "drizzle-orm";
|
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import { config } from "@/lib/config";
|
|
||||||
|
|
||||||
export const cleanupService = {
|
|
||||||
/**
|
|
||||||
* Runs all cleanup tasks
|
|
||||||
*/
|
|
||||||
runAll: async () => {
|
|
||||||
console.log("🧹 Starting system cleanup...");
|
|
||||||
const stats = {
|
|
||||||
lootdrops: 0,
|
|
||||||
timers: 0,
|
|
||||||
quests: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
stats.lootdrops = await cleanupService.cleanupLootdrops();
|
|
||||||
stats.timers = await cleanupService.cleanupTimers();
|
|
||||||
stats.quests = await cleanupService.cleanupQuests();
|
|
||||||
|
|
||||||
console.log(`✅ Cleanup finished. Stats:
|
|
||||||
- Lootdrops: ${stats.lootdrops} removed
|
|
||||||
- Timers: ${stats.timers} removed
|
|
||||||
- Quests: ${stats.quests} cleaned`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Error during cleanup:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes unclaimed expired lootdrops
|
|
||||||
*/
|
|
||||||
cleanupLootdrops: async (): Promise<number> => {
|
|
||||||
const now = new Date();
|
|
||||||
const result = await DrizzleClient.delete(lootdrops)
|
|
||||||
.where(and(
|
|
||||||
lt(lootdrops.expiresAt, now),
|
|
||||||
isNull(lootdrops.claimedBy)
|
|
||||||
))
|
|
||||||
.returning({ id: lootdrops.messageId });
|
|
||||||
|
|
||||||
return result.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up expired user timers and handles side effects (like role removal)
|
|
||||||
*/
|
|
||||||
cleanupTimers: async (): Promise<number> => {
|
|
||||||
const now = new Date();
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
// 1. Specific handling for ACCESS timers (revoking roles etc)
|
|
||||||
// This is migrated from scheduler.ts
|
|
||||||
const expiredAccess = await DrizzleClient.query.userTimers.findMany({
|
|
||||||
where: and(
|
|
||||||
eq(userTimers.type, 'ACCESS'),
|
|
||||||
lt(userTimers.expiresAt, now)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const timer of expiredAccess) {
|
|
||||||
const meta = timer.metadata as any;
|
|
||||||
const userIdStr = timer.userId.toString();
|
|
||||||
|
|
||||||
if (timer.key.startsWith('role_')) {
|
|
||||||
try {
|
|
||||||
const roleId = meta?.roleId || timer.key.replace('role_', '');
|
|
||||||
const guildId = env.DISCORD_GUILD_ID;
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
const guild = await AuroraClient.guilds.fetch(guildId);
|
|
||||||
const member = await guild.members.fetch(userIdStr);
|
|
||||||
await member.roles.remove(roleId);
|
|
||||||
console.log(`👋 Removed temporary role ${roleId} from ${member.user.tag}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to remove role for user ${userIdStr}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete specifically this one
|
|
||||||
await DrizzleClient.delete(userTimers)
|
|
||||||
.where(and(
|
|
||||||
eq(userTimers.userId, timer.userId),
|
|
||||||
eq(userTimers.type, timer.type),
|
|
||||||
eq(userTimers.key, timer.key)
|
|
||||||
));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Bulk delete all other expired timers (COOLDOWN, EFFECT, etc)
|
|
||||||
const result = await DrizzleClient.delete(userTimers)
|
|
||||||
.where(lt(userTimers.expiresAt, now))
|
|
||||||
.returning({ userId: userTimers.userId });
|
|
||||||
|
|
||||||
deletedCount += result.length;
|
|
||||||
return deletedCount;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes or archives old completed quests
|
|
||||||
*/
|
|
||||||
cleanupQuests: async (): Promise<number> => {
|
|
||||||
const archiveDays = config.system.cleanup.questArchiveDays;
|
|
||||||
const threshold = new Date();
|
|
||||||
threshold.setDate(threshold.getDate() - archiveDays);
|
|
||||||
|
|
||||||
const result = await DrizzleClient.delete(userQuests)
|
|
||||||
.where(lt(userQuests.completedAt, threshold))
|
|
||||||
.returning({ userId: userQuests.userId });
|
|
||||||
|
|
||||||
return result.length;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,32 +1,21 @@
|
|||||||
import { cleanupService } from "./cleanup.service";
|
import { temporaryRoleService } from "./temp-role.service";
|
||||||
import { config } from "@/lib/config";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Scheduler responsible for periodic tasks and system maintenance.
|
|
||||||
*/
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||||
|
|
||||||
// 1. High-frequency timer cleanup (every 60s)
|
// 1. Temporary Role Revocation (every 60s)
|
||||||
// This handles role revocations and cooldown expirations
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
cleanupService.cleanupTimers();
|
temporaryRoleService.processExpiredRoles();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// 2. Scheduled system cleanup (configurable, default daily)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
// This handles lootdrops, quests, etc.
|
|
||||||
setInterval(() => {
|
|
||||||
cleanupService.runAll();
|
|
||||||
}, config.system.cleanup.intervalMs);
|
|
||||||
|
|
||||||
// 3. Terminal Update Loop (every 60s)
|
|
||||||
const { terminalService } = require("@/modules/terminal/terminal.service");
|
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Run an initial cleanup on start for good measure
|
// Run an initial check on start
|
||||||
cleanupService.runAll();
|
temporaryRoleService.processExpiredRoles();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
114
src/modules/system/temp-role.service.test.ts
Normal file
114
src/modules/system/temp-role.service.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { temporaryRoleService } from "./temp-role.service";
|
||||||
|
import { userTimers } from "@/db/schema";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
|
const mockDelete = mock();
|
||||||
|
const mockWhere = mock();
|
||||||
|
const mockFindMany = mock();
|
||||||
|
|
||||||
|
mockDelete.mockReturnValue({ where: mockWhere });
|
||||||
|
|
||||||
|
// Mock DrizzleClient
|
||||||
|
mock.module("@/lib/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
delete: mockDelete,
|
||||||
|
query: {
|
||||||
|
userTimers: {
|
||||||
|
findMany: mockFindMany
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AuroraClient
|
||||||
|
const mockRemoveRole = mock();
|
||||||
|
const mockFetchMember = mock();
|
||||||
|
const mockFetchGuild = mock();
|
||||||
|
|
||||||
|
mock.module("@/lib/BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
guilds: {
|
||||||
|
fetch: mockFetchGuild
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@/lib/env", () => ({
|
||||||
|
env: {
|
||||||
|
DISCORD_GUILD_ID: "guild123"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("temporaryRoleService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDelete.mockClear();
|
||||||
|
mockWhere.mockClear();
|
||||||
|
mockFindMany.mockClear();
|
||||||
|
mockRemoveRole.mockClear();
|
||||||
|
mockFetchMember.mockClear();
|
||||||
|
mockFetchGuild.mockClear();
|
||||||
|
|
||||||
|
mockFetchGuild.mockResolvedValue({
|
||||||
|
members: {
|
||||||
|
fetch: mockFetchMember
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetchMember.mockResolvedValue({
|
||||||
|
user: { tag: "TestUser#1234" },
|
||||||
|
roles: { remove: mockRemoveRole }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should revoke expired roles and delete timers", async () => {
|
||||||
|
// Mock findMany to return an expired role timer
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
userId: 123n,
|
||||||
|
type: TimerType.ACCESS,
|
||||||
|
key: 'role_456',
|
||||||
|
expiresAt: new Date(Date.now() - 1000),
|
||||||
|
metadata: { roleId: '456' }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const count = await temporaryRoleService.processExpiredRoles();
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(mockFetchGuild).toHaveBeenCalledWith("guild123");
|
||||||
|
expect(mockFetchMember).toHaveBeenCalledWith("123");
|
||||||
|
expect(mockRemoveRole).toHaveBeenCalledWith("456");
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(userTimers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should still delete the timer even if member is not found", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
userId: 999n,
|
||||||
|
type: TimerType.ACCESS,
|
||||||
|
key: 'role_789',
|
||||||
|
expiresAt: new Date(Date.now() - 1000),
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock member fetch failure
|
||||||
|
mockFetchMember.mockRejectedValue(new Error("Member not found"));
|
||||||
|
|
||||||
|
const count = await temporaryRoleService.processExpiredRoles();
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(mockRemoveRole).not.toHaveBeenCalled();
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(userTimers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 if no expired timers exist", async () => {
|
||||||
|
mockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const count = await temporaryRoleService.processExpiredRoles();
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(mockDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/modules/system/temp-role.service.ts
Normal file
67
src/modules/system/temp-role.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { userTimers } from "@/db/schema";
|
||||||
|
import { eq, and, lt } from "drizzle-orm";
|
||||||
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const temporaryRoleService = {
|
||||||
|
/**
|
||||||
|
* Checks for and revokes expired temporary roles.
|
||||||
|
* This is intended to run as a high-frequency maintenance task.
|
||||||
|
*/
|
||||||
|
processExpiredRoles: async (): Promise<number> => {
|
||||||
|
const now = new Date();
|
||||||
|
let revokedCount = 0;
|
||||||
|
|
||||||
|
// Find all expired ACCESS (temporary role) timers
|
||||||
|
const expiredTimers = await DrizzleClient.query.userTimers.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(userTimers.type, TimerType.ACCESS),
|
||||||
|
lt(userTimers.expiresAt, now)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expiredTimers.length === 0) return 0;
|
||||||
|
|
||||||
|
for (const timer of expiredTimers) {
|
||||||
|
const userIdStr = timer.userId.toString();
|
||||||
|
const meta = timer.metadata as any;
|
||||||
|
|
||||||
|
// We only handle keys that indicate role management
|
||||||
|
if (timer.key.startsWith('role_')) {
|
||||||
|
try {
|
||||||
|
const roleId = meta?.roleId || timer.key.replace('role_', '');
|
||||||
|
const guildId = env.DISCORD_GUILD_ID;
|
||||||
|
|
||||||
|
if (guildId) {
|
||||||
|
const guild = await AuroraClient.guilds.fetch(guildId);
|
||||||
|
const member = await guild.members.fetch(userIdStr).catch(() => null);
|
||||||
|
|
||||||
|
if (member) {
|
||||||
|
await member.roles.remove(roleId);
|
||||||
|
console.log(`👋 Temporary role ${roleId} revoked from ${member.user.tag} (Expired)`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ Could not find member ${userIdStr} to revoke role ${roleId}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to revoke role for user ${userIdStr}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always delete the timer record after trying to revoke (or if it's not a role key)
|
||||||
|
// to prevent repeated failed attempts.
|
||||||
|
await DrizzleClient.delete(userTimers)
|
||||||
|
.where(and(
|
||||||
|
eq(userTimers.userId, timer.userId),
|
||||||
|
eq(userTimers.type, timer.type),
|
||||||
|
eq(userTimers.key, timer.key)
|
||||||
|
));
|
||||||
|
|
||||||
|
revokedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return revokedCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import { TextChannel, Message, ContainerBuilder, TextDisplayBuilder, SectionBuilder, MessageFlags } from "discord.js";
|
import {
|
||||||
|
TextChannel,
|
||||||
|
ContainerBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
SectionBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
ThumbnailBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
SeparatorSpacingSize
|
||||||
|
} from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
import { users, transactions, lootdrops } from "@/db/schema";
|
import { users, transactions, lootdrops, inventory } from "@/db/schema";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc, sql } from "drizzle-orm";
|
||||||
import { config, saveConfig } from "@/lib/config";
|
import { config, saveConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
// Color palette for containers (hex as decimal)
|
||||||
|
const COLORS = {
|
||||||
|
HEADER: 0x9B59B6, // Purple - mystical
|
||||||
|
LEADERS: 0xF1C40F, // Gold - achievement
|
||||||
|
ACTIVITY: 0x3498DB, // Blue - activity
|
||||||
|
ALERT: 0xE74C3C // Red - active events
|
||||||
|
};
|
||||||
|
|
||||||
export const terminalService = {
|
export const terminalService = {
|
||||||
init: async (channel: TextChannel) => {
|
init: async (channel: TextChannel) => {
|
||||||
// limit to one terminal for now
|
// Limit to one terminal for now
|
||||||
if (config.terminal) {
|
if (config.terminal) {
|
||||||
try {
|
try {
|
||||||
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
||||||
@@ -16,11 +33,11 @@ export const terminalService = {
|
|||||||
if (oldMsg) await oldMsg.delete();
|
if (oldMsg) await oldMsg.delete();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore if old message doesn't exist
|
// Ignore if old message doesn't exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = await channel.send({ content: "🔄 Initializing Aurora Observatory..." });
|
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
|
||||||
|
|
||||||
config.terminal = {
|
config.terminal = {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
@@ -48,8 +65,6 @@ export const terminalService = {
|
|||||||
|
|
||||||
const containers = await terminalService.buildMessage();
|
const containers = await terminalService.buildMessage();
|
||||||
|
|
||||||
// Components V2 requires the IsComponentsV2 flag and no content/embeds
|
|
||||||
// Disable allowedMentions to prevent pings from the dashboard
|
|
||||||
await message.edit({
|
await message.edit({
|
||||||
content: null,
|
content: null,
|
||||||
embeds: null as any,
|
embeds: null as any,
|
||||||
@@ -64,24 +79,51 @@ export const terminalService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
buildMessage: async () => {
|
buildMessage: async () => {
|
||||||
// 1. Data Fetching
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// DATA FETCHING
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const allUsers = await DrizzleClient.select().from(users);
|
const allUsers = await DrizzleClient.select().from(users);
|
||||||
const totalUsers = allUsers.length;
|
const totalUsers = allUsers.length;
|
||||||
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
||||||
|
|
||||||
// 2. Leaderboards Calculation
|
// System stats
|
||||||
const topLevels = [...allUsers].sort((a, b) => (b.level || 0) - (a.level || 0)).slice(0, 3);
|
const uptime = process.uptime();
|
||||||
const topWealth = [...allUsers].sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n)).slice(0, 3);
|
const uptimeHours = Math.floor(uptime / 3600);
|
||||||
|
const uptimeMinutes = Math.floor((uptime % 3600) / 60);
|
||||||
|
const ping = AuroraClient.ws.ping;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
const formatUser = (u: typeof users.$inferSelect, i: number) => {
|
// Guild member count (if available)
|
||||||
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
|
const guild = AuroraClient.guilds.cache.first();
|
||||||
return `${star} <@${u.id}>`;
|
const memberCount = guild?.memberCount ?? totalUsers;
|
||||||
};
|
|
||||||
|
|
||||||
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
|
// Additional metrics
|
||||||
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*";
|
const avgLevel = totalUsers > 0
|
||||||
|
? Math.round(allUsers.reduce((acc, u) => acc + (u.level || 1), 0) / totalUsers)
|
||||||
|
: 1;
|
||||||
|
const topStreak = allUsers.reduce((max, u) => Math.max(max, u.dailyStreak || 0), 0);
|
||||||
|
|
||||||
// 3. Lootdrops Data
|
// Items in circulation
|
||||||
|
const itemsResult = await DrizzleClient
|
||||||
|
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
|
||||||
|
.from(inventory);
|
||||||
|
const totalItems = Number(itemsResult[0]?.total || 0);
|
||||||
|
|
||||||
|
// Last command timestamp
|
||||||
|
const lastCmd = AuroraClient.lastCommandTimestamp
|
||||||
|
? `<t:${Math.floor(AuroraClient.lastCommandTimestamp / 1000)}:R>`
|
||||||
|
: "*Never*";
|
||||||
|
|
||||||
|
// Leaderboards
|
||||||
|
const topLevels = [...allUsers]
|
||||||
|
.sort((a, b) => (b.level || 0) - (a.level || 0))
|
||||||
|
.slice(0, 3);
|
||||||
|
const topWealth = [...allUsers]
|
||||||
|
.sort((a, b) => Number(b.balance || 0n) - Number(a.balance || 0n))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Lootdrops
|
||||||
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||||
limit: 1,
|
limit: 1,
|
||||||
@@ -94,65 +136,166 @@ export const terminalService = {
|
|||||||
orderBy: desc(lootdrops.createdAt)
|
orderBy: desc(lootdrops.createdAt)
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- CONTAINER 1: Header ---
|
// Recent transactions
|
||||||
const headerContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("# 🌌 AURORA OBSERVATORY"),
|
|
||||||
new TextDisplayBuilder().setContent("*Current Moon Phase: Waxing Crescent 🌒*")
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 2: Observation Log ---
|
|
||||||
let phenomenaContent = "";
|
|
||||||
|
|
||||||
if (activeDrops.length > 0 && activeDrops[0]) {
|
|
||||||
const drop = activeDrops[0];
|
|
||||||
phenomenaContent = `\n**SHOOTING STAR DETECTED**\nRadiance: \`${drop.rewardAmount} ${drop.currency}\`\nCoordinates: <#${drop.channelId}>\nImpact: <t:${Math.floor(drop.expiresAt!.getTime() / 1000)}:R>`;
|
|
||||||
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
|
||||||
const drop = recentDrops[0];
|
|
||||||
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
|
||||||
phenomenaContent = `\n**RECENT EVENT**\nStar yielded \`${drop.rewardAmount} ${drop.currency}\` to ${claimer ? `<@${claimer.id}>` : '**Unknown**'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("## 🔭 OBSERVATION LOG"),
|
|
||||||
new TextDisplayBuilder().setContent(`> **Stargazers**: \`${totalUsers}\`\n> **Astral Wealth**: \`${totalWealth.toLocaleString()} AU\`${phenomenaContent}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 3: Leaders ---
|
|
||||||
const leaderContainer = new ContainerBuilder()
|
|
||||||
.addTextDisplayComponents(
|
|
||||||
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS"),
|
|
||||||
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelText}`),
|
|
||||||
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthText}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- CONTAINER 4: Echoes ---
|
|
||||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||||
limit: 5,
|
limit: 3,
|
||||||
orderBy: [desc(transactions.createdAt)]
|
orderBy: [desc(transactions.createdAt)]
|
||||||
});
|
});
|
||||||
|
|
||||||
const activityLines = recentTx.map(tx => {
|
// ═══════════════════════════════════════════════════════════
|
||||||
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
// HELPER FORMATTERS
|
||||||
let icon = "💫";
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (tx.type.includes("LOOT")) icon = "🌠";
|
|
||||||
if (tx.type.includes("GIFT")) icon = "🌕";
|
|
||||||
const user = allUsers.find(u => u.id === tx.userId);
|
|
||||||
|
|
||||||
// the description might contain a channel id all the way at the end
|
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
|
||||||
const channelId = tx.description?.split(" ").pop() || "";
|
|
||||||
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
|
|
||||||
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const echoesContainer = new ContainerBuilder()
|
const formatLeaderEntry = (u: typeof users.$inferSelect, i: number, type: 'level' | 'wealth') => {
|
||||||
|
const medal = getMedal(i);
|
||||||
|
const value = type === 'level'
|
||||||
|
? `Lvl ${u.level ?? 1}`
|
||||||
|
: `${Number(u.balance ?? 0).toLocaleString()} AU`;
|
||||||
|
return `${medal} **${u.username}** — ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActivityIcon = (type: 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 "📜";
|
||||||
|
return "💫";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 1: HEADER - Station Overview
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const botAvatar = AuroraClient.user?.displayAvatarURL({ size: 64 }) ?? "";
|
||||||
|
|
||||||
|
const headerSection = new SectionBuilder()
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
|
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
|
||||||
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
|
new TextDisplayBuilder().setContent("-# Real-time server observatory")
|
||||||
|
)
|
||||||
|
.setThumbnailAccessory(
|
||||||
|
new ThumbnailBuilder().setURL(botAvatar)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const statsText = [
|
||||||
|
`📡 **Uptime** ${uptimeHours}h ${uptimeMinutes}m`,
|
||||||
|
`🏓 **Ping** ${ping}ms`,
|
||||||
|
`👥 **Students** ${totalUsers}`,
|
||||||
|
`🪙 **Economy** ${totalWealth.toLocaleString()} AU`
|
||||||
|
].join(" • ");
|
||||||
|
|
||||||
return [headerContainer, logContainer, leaderContainer, echoesContainer];
|
const secondaryStats = [
|
||||||
|
`📦 **Items** ${totalItems.toLocaleString()}`,
|
||||||
|
`📈 **Avg Lvl** ${avgLevel}`,
|
||||||
|
`🔥 **Top Streak** ${topStreak}d`,
|
||||||
|
`⚡ **Last Cmd** ${lastCmd}`
|
||||||
|
].join(" • ");
|
||||||
|
|
||||||
|
const headerContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.HEADER)
|
||||||
|
.addSectionComponents(headerSection)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(statsText),
|
||||||
|
new TextDisplayBuilder().setContent(secondaryStats),
|
||||||
|
new TextDisplayBuilder().setContent(`-# Updated <t:${now}:R>`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 2: LEADERBOARDS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const levelLeaderText = topLevels.length > 0
|
||||||
|
? topLevels.map((u, i) => formatLeaderEntry(u, i, 'level')).join("\n")
|
||||||
|
: "*No data yet*";
|
||||||
|
|
||||||
|
const wealthLeaderText = topWealth.length > 0
|
||||||
|
? topWealth.map((u, i) => formatLeaderEntry(u, i, 'wealth')).join("\n")
|
||||||
|
: "*No data yet*";
|
||||||
|
|
||||||
|
const leadersContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(COLORS.LEADERS)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## ✨ CONSTELLATION LEADERS")
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small))
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**Brightest Stars**\n${levelLeaderText}`),
|
||||||
|
new TextDisplayBuilder().setContent(`**Gilded Nebulas**\n${wealthLeaderText}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CONTAINER 3: LIVE ACTIVITY
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Determine if there's an active lootdrop
|
||||||
|
const hasActiveDrop = activeDrops.length > 0 && activeDrops[0];
|
||||||
|
const activityColor = hasActiveDrop ? COLORS.ALERT : COLORS.ACTIVITY;
|
||||||
|
|
||||||
|
const activityContainer = new ContainerBuilder()
|
||||||
|
.setAccentColor(activityColor)
|
||||||
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## 🌠 LIVE ACTIVITY")
|
||||||
|
)
|
||||||
|
.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
|
// Active lootdrop or recent event
|
||||||
|
if (hasActiveDrop) {
|
||||||
|
const drop = activeDrops[0]!;
|
||||||
|
const expiresTimestamp = Math.floor(drop.expiresAt!.getTime() / 1000);
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`🚨 **SHOOTING STAR ACTIVE**\n` +
|
||||||
|
`> **Reward:** \`${drop.rewardAmount} ${drop.currency}\`\n` +
|
||||||
|
`> **Location:** <#${drop.channelId}>\n` +
|
||||||
|
`> **Expires:** <t:${expiresTimestamp}:R>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (recentDrops.length > 0 && recentDrops[0]) {
|
||||||
|
const drop = recentDrops[0];
|
||||||
|
const claimer = allUsers.find(u => u.id === drop.claimedBy);
|
||||||
|
const claimedTimestamp = drop.createdAt ? Math.floor(drop.createdAt.getTime() / 1000) : now;
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`✅ **Last Star Claimed**\n` +
|
||||||
|
`> **${claimer?.username ?? 'Unknown'}** collected \`${drop.rewardAmount} ${drop.currency}\` <t:${claimedTimestamp}:R>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`-# The sky is quiet... waiting for the next star.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent transactions
|
||||||
|
if (recentTx.length > 0) {
|
||||||
|
activityContainer.addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)
|
||||||
|
);
|
||||||
|
|
||||||
|
const txLines = recentTx.map(tx => {
|
||||||
|
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
||||||
|
const icon = getActivityIcon(tx.type);
|
||||||
|
const user = allUsers.find(u => u.id === tx.userId);
|
||||||
|
|
||||||
|
// Clean description (remove trailing channel IDs)
|
||||||
|
let desc = tx.description || "Unknown";
|
||||||
|
desc = desc.replace(/\s*\d{17,19}\s*$/, "").trim();
|
||||||
|
|
||||||
|
return `${icon} **${user?.username ?? 'Unknown'}**: ${desc} · <t:${time}:R>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
activityContainer.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("**Recent Echoes**"),
|
||||||
|
new TextDisplayBuilder().setContent(txLines.join("\n"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [headerContainer, leadersContainer, activityContainer];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { inventoryService } from "@/modules/inventory/inventory.service";
|
|||||||
import { itemTransactions } from "@/db/schema";
|
import { itemTransactions } from "@/db/schema";
|
||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { TransactionType, ItemTransactionType } from "@/lib/constants";
|
||||||
|
|
||||||
// Module-level session storage
|
// Module-level session storage
|
||||||
const sessions = new Map<string, TradeSession>();
|
const sessions = new Map<string, TradeSession>();
|
||||||
@@ -25,7 +26,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
await economyService.modifyUserBalance(
|
await economyService.modifyUserBalance(
|
||||||
from.id,
|
from.id,
|
||||||
-from.offer.money,
|
-from.offer.money,
|
||||||
'TRADE_OUT',
|
TransactionType.TRADE_OUT,
|
||||||
`Trade with ${to.username} (Thread: ${threadId})`,
|
`Trade with ${to.username} (Thread: ${threadId})`,
|
||||||
to.id,
|
to.id,
|
||||||
tx
|
tx
|
||||||
@@ -33,7 +34,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
await economyService.modifyUserBalance(
|
await economyService.modifyUserBalance(
|
||||||
to.id,
|
to.id,
|
||||||
from.offer.money,
|
from.offer.money,
|
||||||
'TRADE_IN',
|
TransactionType.TRADE_IN,
|
||||||
`Trade with ${from.username} (Thread: ${threadId})`,
|
`Trade with ${from.username} (Thread: ${threadId})`,
|
||||||
from.id,
|
from.id,
|
||||||
tx
|
tx
|
||||||
@@ -54,7 +55,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
relatedUserId: BigInt(to.id),
|
relatedUserId: BigInt(to.id),
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
quantity: -item.quantity,
|
quantity: -item.quantity,
|
||||||
type: 'TRADE_OUT',
|
type: ItemTransactionType.TRADE_OUT,
|
||||||
description: `Traded to ${to.username}`,
|
description: `Traded to ${to.username}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ const processTransfer = async (tx: Transaction, from: TradeParticipant, to: Trad
|
|||||||
relatedUserId: BigInt(from.id),
|
relatedUserId: BigInt(from.id),
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
type: 'TRADE_IN',
|
type: ItemTransactionType.TRADE_IN,
|
||||||
description: `Received from ${from.username}`,
|
description: `Received from ${from.username}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { userTimers } from "@/db/schema";
|
import { userTimers } from "@/db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@/lib/DrizzleClient";
|
import { DrizzleClient } from "@/lib/DrizzleClient";
|
||||||
|
import { TimerType } from "@/lib/constants";
|
||||||
|
|
||||||
export type TimerType = 'COOLDOWN' | 'EFFECT' | 'ACCESS';
|
export { TimerType };
|
||||||
|
|
||||||
export const userTimerService = {
|
export const userTimerService = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
68
src/web/public/style.css
Normal file
68
src/web/public/style.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #0f172a;
|
||||||
|
--text-color: #f8fafc;
|
||||||
|
--accent-color: #38bdf8;
|
||||||
|
--card-bg: #1e293b;
|
||||||
|
--font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
52
src/web/router.test.ts
Normal file
52
src/web/router.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
describe("Web Router", () => {
|
||||||
|
it("should return home page on /", async () => {
|
||||||
|
const req = new Request("http://localhost/");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("text/html");
|
||||||
|
expect(await res.text()).toContain("Aurora Web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return health check on /health", async () => {
|
||||||
|
const req = new Request("http://localhost/health");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("application/json");
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data).toHaveProperty("status", "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block path traversal", async () => {
|
||||||
|
// Attempts to go up two directories to reach the project root or src
|
||||||
|
const req = new Request("http://localhost/public/../../package.json");
|
||||||
|
const res = await router(req);
|
||||||
|
// Should be 403 Forbidden or 404 Not Found (our logical change makes it 403)
|
||||||
|
expect([403, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serve existing static file", async () => {
|
||||||
|
// We know style.css exists in src/web/public
|
||||||
|
const req = new Request("http://localhost/public/style.css");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
if (res.status === 200) {
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain("body");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not serve static files on non-GET methods", async () => {
|
||||||
|
const req = new Request("http://localhost/public/style.css", { method: "POST" });
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for unknown routes", async () => {
|
||||||
|
const req = new Request("http://localhost/unknown");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/web/router.ts
Normal file
53
src/web/router.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { homeRoute } from "./routes/home";
|
||||||
|
import { healthRoute } from "./routes/health";
|
||||||
|
import { file } from "bun";
|
||||||
|
import { join, resolve } from "path";
|
||||||
|
|
||||||
|
export async function router(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Resolve the absolute path to the public directory
|
||||||
|
const publicDir = resolve(import.meta.dir, "public");
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
// Handle Static Files
|
||||||
|
// We handle requests starting with /public/ OR containing an extension (like /style.css)
|
||||||
|
if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) {
|
||||||
|
// Normalize path: remove /public prefix if present so that
|
||||||
|
// /public/style.css and /style.css both map to .../public/style.css
|
||||||
|
const relativePath = url.pathname.replace(/^\/public/, "");
|
||||||
|
|
||||||
|
// Resolve full path
|
||||||
|
// We use join with relativePath. If relativePath starts with /, join handles it correctly
|
||||||
|
// effectively treating it as a segment.
|
||||||
|
// However, to be extra safe with 'resolve', we ensure we are resolving from publicDir.
|
||||||
|
// simple join(publicDir, relativePath) is usually enough with 'bun'.
|
||||||
|
// But we use 'resolve' to handle .. segments correctly.
|
||||||
|
// We prepend '.' to relativePath to ensure it's treated as relative to publicDir logic
|
||||||
|
const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath;
|
||||||
|
const requestedPath = resolve(publicDir, normalizedRelative);
|
||||||
|
|
||||||
|
// Security Check: Block Path Traversal
|
||||||
|
if (requestedPath.startsWith(publicDir)) {
|
||||||
|
const staticFile = file(requestedPath);
|
||||||
|
if (await staticFile.exists()) {
|
||||||
|
return new Response(staticFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If path traversal detected, return 403 or 404.
|
||||||
|
// 403 indicates we caught them.
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
|
return homeRoute();
|
||||||
|
}
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return healthRoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
9
src/web/routes/health.ts
Normal file
9
src/web/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function healthRoute(): Response {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/web/routes/home.ts
Normal file
20
src/web/routes/home.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseLayout } from "../views/layout";
|
||||||
|
|
||||||
|
export function homeRoute(): Response {
|
||||||
|
const content = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>The Aurora web server is up and running!</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Status</h3>
|
||||||
|
<p>System operational.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const html = BaseLayout({ title: "Home", content });
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/web/server.ts
Normal file
24
src/web/server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { router } from "./router";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
|
||||||
|
export class WebServer {
|
||||||
|
private static server: Server<unknown> | null = null;
|
||||||
|
|
||||||
|
public static start() {
|
||||||
|
this.server = Bun.serve({
|
||||||
|
port: env.PORT || 3000,
|
||||||
|
fetch: router,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stop() {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.stop();
|
||||||
|
console.log("🛑 Web server stopped");
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/web/utils/html.test.ts
Normal file
17
src/web/utils/html.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { escapeHtml } from "./html";
|
||||||
|
|
||||||
|
describe("HTML Utils", () => {
|
||||||
|
it("should escape special characters", () => {
|
||||||
|
const unsafe = '<script>alert("xss")</script>';
|
||||||
|
const safe = escapeHtml(unsafe);
|
||||||
|
expect(safe).toBe("<script>alert("xss")</script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed content", () => {
|
||||||
|
const unsafe = 'Hello & "World"';
|
||||||
|
const safe = escapeHtml(unsafe);
|
||||||
|
expect(safe).toBe("Hello & "World"");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/web/utils/html.ts
Normal file
14
src/web/utils/html.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes unsafe characters in a string to prevent XSS.
|
||||||
|
* @param unsafe - The raw string to escape.
|
||||||
|
* @returns The escaped string safe for HTML insertion.
|
||||||
|
*/
|
||||||
|
export function escapeHtml(unsafe: string): string {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
34
src/web/views/layout.ts
Normal file
34
src/web/views/layout.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { escapeHtml } from "../utils/html";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseLayout({ title, content }: LayoutProps): string {
|
||||||
|
const safeTitle = escapeHtml(title);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${safeTitle} | Aurora</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<meta name="description" content="Aurora Bot Web Interface">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Aurora Web</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>© ${new Date().getFullYear()} Aurora Bot</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
59
tickets/2026-01-07-web-server-foundation.md
Normal file
59
tickets/2026-01-07-web-server-foundation.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 2026-01-07-web-server-foundation: Web Server Infrastructure Foundation
|
||||||
|
|
||||||
|
**Status:** Done
|
||||||
|
**Created:** 2026-01-07
|
||||||
|
**Tags:** infrastructure, web, core
|
||||||
|
|
||||||
|
## 1. Context & User Story
|
||||||
|
* **As a:** Developer
|
||||||
|
* **I want to:** Establish a lightweight, integrated web server foundation within the existing codebase.
|
||||||
|
* **So that:** We can serve internal tools (Workbench) or public pages (Leaderboard) with minimal friction, avoiding complex separate build pipelines.
|
||||||
|
|
||||||
|
## 2. Technical Requirements
|
||||||
|
### Architecture
|
||||||
|
- **Native Bun Server:** Use `Bun.serve()` for high performance.
|
||||||
|
- **Exposure:** The server port must be exposed in `docker-compose.yml` to be accessible outside the container.
|
||||||
|
- **Rendering Strategy:** **Server-Side Rendering (SSR) via Template Literals**.
|
||||||
|
- *Why?* Zero dependencies. No build step (like Vite/Webpack) required. We can simply write functions that return HTML strings.
|
||||||
|
- *Client Side:* Minimal Vanilla JS or a lightweight drop-in library (like HTMX or Alpine from CDN) can be used if interactivity is needed later.
|
||||||
|
|
||||||
|
### File Organization (`src/web/`)
|
||||||
|
We will separate the web infrastructure from game modules to keep concerns clean.
|
||||||
|
- `src/web/server.ts`: Main server class/entry point.
|
||||||
|
- `src/web/router.ts`: Simple routing logic.
|
||||||
|
- `src/web/routes/`: Individual route handlers (e.g., `home.ts`, `health.ts`).
|
||||||
|
- `src/web/views/`: Reusable HTML template functions (Header, Footer, Layouts).
|
||||||
|
- `src/web/public/`: Static assets (CSS, Images) served directly.
|
||||||
|
|
||||||
|
### API / Interface
|
||||||
|
- **GET /health**: Returns `{ status: "ok", uptime: <seconds> }`.
|
||||||
|
- **GET /**: Renders a basic HTML landing page using the View system.
|
||||||
|
|
||||||
|
## 3. Constraints & Validations (CRITICAL)
|
||||||
|
- **Zero Frameworks:** No Express/NestJS.
|
||||||
|
- **Zero Build Tools:** No Webpack/Vite. The code must be runnable directly by `bun run`.
|
||||||
|
- **Docker Integration:** Port 3000 (or env `PORT`) must be mapped in Docker Compose.
|
||||||
|
- **Static Files:** Must implement a handler to check `src/web/public` for file requests.
|
||||||
|
|
||||||
|
## 4. Acceptance Criteria
|
||||||
|
1. [x] `docker-compose up` exposes port 3000.
|
||||||
|
2. [x] `http://localhost:3000` loads a styled HTML page (verifying static asset serving + SSR).
|
||||||
|
3. [x] `http://localhost:3000/health` returns JSON.
|
||||||
|
4. [x] Folder structure established as defined above.
|
||||||
|
|
||||||
|
## 5. Implementation Plan
|
||||||
|
- [x] **Infrastructure**: Create `src/web/` directory structure.
|
||||||
|
- [x] **Core Logic**: Implement `WebServer` class in `src/web/server.ts` with routing and static file serving logic.
|
||||||
|
- [x] **Integration**: Bind `WebServer.start()` to `src/index.ts`.
|
||||||
|
- [x] **Docker**: Update `docker-compose.yml` to map port `3000:3000`.
|
||||||
|
- [x] **Views**: Create a basic `BaseLayout` function in `src/web/views/layout.ts`.
|
||||||
|
- [x] **Env**: Add `PORT` to `config.ts` / `env.ts`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- Created `src/web` directory with `router.ts`, `server.ts` and subdirectories `routes`, `views`, `public`.
|
||||||
|
- Implemented `WebServer` class using `Bun.serve`.
|
||||||
|
- Added basic CSS and layout system.
|
||||||
|
- Added `PORT` to `src/lib/env.ts` (default 3000).
|
||||||
|
- Integrated into `src/index.ts` to start on boot and graceful shutdown.
|
||||||
|
- Fixed unrelated typing issues in `src/commands/admin/note.ts` and `src/db/indexes.test.ts` to pass strict CI checks.
|
||||||
|
- Verified with `bun test` and `bun x tsc`.
|
||||||
Reference in New Issue
Block a user