Compare commits
12 Commits
dd62336571
...
feat/web-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894cad91a8 | ||
|
|
2a1c4e65ae | ||
|
|
022f748517 | ||
|
|
ca392749e3 | ||
|
|
4a1e72c5f3 | ||
|
|
d29a1ec2b7 | ||
|
|
1dd269bf2f | ||
|
|
69186ff3e9 | ||
|
|
b989e807dc | ||
|
|
2e6bdec38c | ||
|
|
a9d5c806ad | ||
|
|
6f73178375 |
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/log
|
||||
scratchpad/
|
||||
tickets/
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
ports:
|
||||
- "${DB_PORT}:5432"
|
||||
- "127.0.0.1:${DB_PORT}:5432"
|
||||
volumes:
|
||||
- ./src/db/data:/var/lib/postgresql/data
|
||||
- ./src/db/log:/var/log/postgresql
|
||||
@@ -46,7 +46,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "4983:4983"
|
||||
- "127.0.0.1:4983:4983"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"postgres": "^3.4.7"
|
||||
"drizzle-kit": "^0.31.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
@@ -26,6 +25,7 @@
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7",
|
||||
"zod": "^4.1.13"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createCommand } from "@/lib/utils";
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||
import { CaseType } from "@/lib/constants";
|
||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||
|
||||
export const note = createCommand({
|
||||
@@ -31,7 +32,7 @@ export const note = createCommand({
|
||||
|
||||
// Create the note case
|
||||
const moderationCase = await ModerationService.createCase({
|
||||
type: 'note',
|
||||
type: CaseType.NOTE,
|
||||
userId: targetUser.id,
|
||||
username: targetUser.username,
|
||||
moderatorId: interaction.user.id,
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("Database Indexes", () => {
|
||||
SELECT indexname FROM pg_indexes
|
||||
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_level_xp_idx");
|
||||
});
|
||||
@@ -17,7 +17,7 @@ describe("Database Indexes", () => {
|
||||
SELECT indexname FROM pg_indexes
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Database Indexes", () => {
|
||||
SELECT indexname FROM pg_indexes
|
||||
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_case_id_idx");
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe("Database Indexes", () => {
|
||||
SELECT indexname FROM pg_indexes
|
||||
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_lookup_idx");
|
||||
});
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,11 +1,14 @@
|
||||
import { AuroraClient } from "@/lib/BotClient";
|
||||
import { env } from "@lib/env";
|
||||
|
||||
import { WebServer } from "@/web/server";
|
||||
|
||||
// Load commands & events
|
||||
await AuroraClient.loadCommands();
|
||||
await AuroraClient.loadEvents();
|
||||
await AuroraClient.deployCommands();
|
||||
|
||||
WebServer.start();
|
||||
|
||||
// login with the token from .env
|
||||
if (!env.DISCORD_BOT_TOKEN) {
|
||||
@@ -14,5 +17,10 @@ if (!env.DISCORD_BOT_TOKEN) {
|
||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("SIGINT", () => AuroraClient.shutdown());
|
||||
process.on("SIGTERM", () => AuroraClient.shutdown());
|
||||
const shutdownHandler = () => {
|
||||
WebServer.stop();
|
||||
AuroraClient.shutdown();
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdownHandler);
|
||||
process.on("SIGTERM", shutdownHandler);
|
||||
@@ -69,12 +69,7 @@ export interface GameConfigType {
|
||||
autoTimeoutThreshold?: number;
|
||||
};
|
||||
};
|
||||
system: {
|
||||
cleanup: {
|
||||
intervalMs: number;
|
||||
questArchiveDays: number;
|
||||
};
|
||||
};
|
||||
system: Record<string, any>;
|
||||
}
|
||||
|
||||
// Initial default config state
|
||||
@@ -167,17 +162,7 @@ const configSchema = z.object({
|
||||
dmOnWarn: true
|
||||
}
|
||||
}),
|
||||
system: z.object({
|
||||
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
|
||||
}
|
||||
})
|
||||
system: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
export function reloadConfig() {
|
||||
|
||||
@@ -5,6 +5,7 @@ const envSchema = z.object({
|
||||
DISCORD_CLIENT_ID: z.string().optional(),
|
||||
DISCORD_GUILD_ID: z.string().optional(),
|
||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class CommandLoader {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,15 @@ describe("economyService", () => {
|
||||
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 () => {
|
||||
// Mock user at streak 7.
|
||||
// Mock time as 24h + 1m after expiry.
|
||||
|
||||
@@ -68,8 +68,6 @@ export const economyService = {
|
||||
claimDaily: async (userId: string, tx?: Transaction) => {
|
||||
return await withTransaction(async (txFn) => {
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
// Check cooldown
|
||||
const cooldown = await txFn.query.userTimers.findFirst({
|
||||
@@ -90,17 +88,23 @@ export const economyService = {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
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 {
|
||||
streak = 1;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class LootdropService {
|
||||
// Cleanup interval for activity tracking and expired lootdrops
|
||||
setInterval(() => {
|
||||
this.cleanupActivity();
|
||||
this.cleanupExpiredLootdrops();
|
||||
this.cleanupExpiredLootdrops(true);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
@@ -45,16 +45,24 @@ class LootdropService {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupExpiredLootdrops() {
|
||||
public async cleanupExpiredLootdrops(includeClaimed: boolean = false): Promise<number> {
|
||||
try {
|
||||
const now = new Date();
|
||||
await DrizzleClient.delete(lootdrops)
|
||||
.where(and(
|
||||
isNull(lootdrops.claimedBy),
|
||||
lt(lootdrops.expiresAt, now)
|
||||
));
|
||||
const whereClause = includeClaimed
|
||||
? lt(lootdrops.expiresAt, now)
|
||||
: and(isNull(lootdrops.claimedBy), 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) {
|
||||
console.error("Failed to cleanup lootdrops:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,119 +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";
|
||||
import { TimerType } from "@/lib/constants";
|
||||
|
||||
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, TimerType.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 { config } from "@/lib/config";
|
||||
import { temporaryRoleService } from "./temp-role.service";
|
||||
|
||||
/**
|
||||
* The Scheduler responsible for periodic tasks and system maintenance.
|
||||
*/
|
||||
export const schedulerService = {
|
||||
start: () => {
|
||||
console.log("🕒 Scheduler started: Maintenance loops initialized.");
|
||||
|
||||
// 1. High-frequency timer cleanup (every 60s)
|
||||
// This handles role revocations and cooldown expirations
|
||||
// 1. Temporary Role Revocation (every 60s)
|
||||
setInterval(() => {
|
||||
cleanupService.cleanupTimers();
|
||||
temporaryRoleService.processExpiredRoles();
|
||||
}, 60 * 1000);
|
||||
|
||||
// 2. Scheduled system cleanup (configurable, default daily)
|
||||
// This handles lootdrops, quests, etc.
|
||||
setInterval(() => {
|
||||
cleanupService.runAll();
|
||||
}, config.system.cleanup.intervalMs);
|
||||
|
||||
// 3. Terminal Update Loop (every 60s)
|
||||
// 2. Terminal Update Loop (every 60s)
|
||||
const { terminalService } = require("@/modules/terminal/terminal.service");
|
||||
setInterval(() => {
|
||||
terminalService.update();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Run an initial cleanup on start for good measure
|
||||
cleanupService.runAll();
|
||||
// Run an initial check on start
|
||||
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 { DrizzleClient } from "@/lib/DrizzleClient";
|
||||
import { users, transactions, lootdrops } from "@/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { users, transactions, lootdrops, inventory } from "@/db/schema";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
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 = {
|
||||
init: async (channel: TextChannel) => {
|
||||
// limit to one terminal for now
|
||||
// Limit to one terminal for now
|
||||
if (config.terminal) {
|
||||
try {
|
||||
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
|
||||
@@ -16,11 +33,11 @@ export const terminalService = {
|
||||
if (oldMsg) await oldMsg.delete();
|
||||
}
|
||||
} 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 = {
|
||||
channelId: channel.id,
|
||||
@@ -48,8 +65,6 @@ export const terminalService = {
|
||||
|
||||
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({
|
||||
content: null,
|
||||
embeds: null as any,
|
||||
@@ -64,24 +79,51 @@ export const terminalService = {
|
||||
},
|
||||
|
||||
buildMessage: async () => {
|
||||
// 1. Data Fetching
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// DATA FETCHING
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const allUsers = await DrizzleClient.select().from(users);
|
||||
const totalUsers = allUsers.length;
|
||||
const totalWealth = allUsers.reduce((acc, u) => acc + (u.balance || 0n), 0n);
|
||||
|
||||
// 2. Leaderboards Calculation
|
||||
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);
|
||||
// System stats
|
||||
const uptime = process.uptime();
|
||||
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) => {
|
||||
const star = i === 0 ? "🌟" : i === 1 ? "⭐" : "✨";
|
||||
return `${star} <@${u.id}>`;
|
||||
};
|
||||
// Guild member count (if available)
|
||||
const guild = AuroraClient.guilds.cache.first();
|
||||
const memberCount = guild?.memberCount ?? totalUsers;
|
||||
|
||||
const levelText = topLevels.map((u, i) => `> ${formatUser(u, i)} • Lvl ${u.level}`).join("\n") || "> *The sky is empty...*";
|
||||
const wealthText = topWealth.map((u, i) => `> ${formatUser(u, i)} • ${u.balance} AU`).join("\n") || "> *The sky is empty...*";
|
||||
// Additional metrics
|
||||
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({
|
||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||
limit: 1,
|
||||
@@ -94,65 +136,166 @@ export const terminalService = {
|
||||
orderBy: desc(lootdrops.createdAt)
|
||||
});
|
||||
|
||||
// --- CONTAINER 1: Header ---
|
||||
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 ---
|
||||
// Recent transactions
|
||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||
limit: 5,
|
||||
limit: 3,
|
||||
orderBy: [desc(transactions.createdAt)]
|
||||
});
|
||||
|
||||
const activityLines = recentTx.map(tx => {
|
||||
const time = Math.floor(tx.createdAt!.getTime() / 1000);
|
||||
let icon = "💫";
|
||||
if (tx.type.includes("LOOT")) icon = "🌠";
|
||||
if (tx.type.includes("GIFT")) icon = "🌕";
|
||||
const user = allUsers.find(u => u.id === tx.userId);
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// HELPER FORMATTERS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// the description might contain a channel id all the way at the end
|
||||
const channelId = tx.description?.split(" ").pop() || "";
|
||||
const text = tx.description?.replace(channelId, "<#" + channelId + ">") || "";
|
||||
return `<t:${time}:F> ${icon} ${user ? `<@${user.id}>` : '**Unknown**'}: ${text}`;
|
||||
});
|
||||
const getMedal = (i: number) => i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉";
|
||||
|
||||
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(
|
||||
new TextDisplayBuilder().setContent("## 📡 COSMIC ECHOES"),
|
||||
new TextDisplayBuilder().setContent(activityLines.join("\n") || "Silence...")
|
||||
new TextDisplayBuilder().setContent("# 🔮 AURORA STATION"),
|
||||
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];
|
||||
}
|
||||
};
|
||||
|
||||
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