From 6e57ab07e4fb1f1b9bf72102a15878ef1eb9a9d9 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 15:12:51 +0100 Subject: [PATCH 01/32] chore: update gitiignore --- .agent/workflows/create-ticket.md | 63 -------------------- .agent/workflows/map-impact.md | 89 --------------------------- .agent/workflows/review.md | 72 ---------------------- .agent/workflows/work.md | 99 ------------------------------- .gitignore | 2 + 5 files changed, 2 insertions(+), 323 deletions(-) delete mode 100644 .agent/workflows/create-ticket.md delete mode 100644 .agent/workflows/map-impact.md delete mode 100644 .agent/workflows/review.md delete mode 100644 .agent/workflows/work.md diff --git a/.agent/workflows/create-ticket.md b/.agent/workflows/create-ticket.md deleted file mode 100644 index a874ac9..0000000 --- a/.agent/workflows/create-ticket.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -description: Converts conversational brain dumps into structured, metric-driven Markdown tickets in the ./tickets directory. ---- - -# WORKFLOW: PRAGMATIC ARCHITECT TICKET GENERATOR - -## 1. High-Level Goal -Transform informal user "brain dumps" into high-precision, metric-driven engineering tickets stored as Markdown files in the `./tickets/` directory. The workflow enforces a quality gate via targeted inquiry before any file persistence occurs, ensuring all tasks are observable, measurable, and actionable. - -## 2. Assumptions & Clarifications -- **Assumptions:** The agent has write access to the `./tickets/` and `/temp/` directories. The current date is accessible for naming conventions. "Metrics" refer to quantifiable constraints (latency, line counts, status codes). -- **Ambiguities:** If the user provides a second brain dump while a ticket is in progress, the agent will prioritize the current workflow until completion or explicit cancellation. - -## 3. Stage Breakdown - -### Stage 1: Discovery & Quality Gate -- **Stage Name:** Requirement Analysis -- **Purpose:** Analyze input for vagueness and enforce the "Quality Gate" by extracting metrics. -- **Inputs:** Raw user brain dump (text). -- **Actions:** 1. Identify "Known Unknowns" (vague terms like "fast," "better," "clean"). - 2. Formulate exactly three (3) targeted questions to convert vague goals into comparable metrics. - 3. Check for logical inconsistencies in the request. -- **Outputs:** Three questions presented to the user. -- **Persistence Strategy:** Save the original brain dump and the three questions to `/temp/pending_ticket_state.json`. - -### Stage 2: Drafting & Refinement -- **Stage Name:** Ticket Drafting -- **Purpose:** Synthesize the original dump and user answers into a structured Markdown draft. -- **Inputs:** User responses to the three questions; `/temp/pending_ticket_state.json`. -- **Actions:** 1. Construct a Markdown draft using the provided template. - 2. Generate a slug-based filename: `YYYYMMDD-slug.md`. - 3. Present the draft and filename to the user for review. -- **Outputs:** Formatted Markdown text and suggested filename displayed in the chat. -- **Persistence Strategy:** Update `/temp/pending_ticket_state.json` with the full Markdown content and the proposed filename. - -### Stage 3: Execution & Persistence -- **Stage Name:** Finalization -- **Purpose:** Commit the approved ticket to the permanent `./tickets/` directory. -- **Inputs:** User confirmation (e.g., "Go," "Approved"); `/temp/pending_ticket_state.json`. -- **Actions:** 1. Write the finalized Markdown content to `./tickets/[filename]`. - 2. Delete the temporary state file in `/temp/`. -- **Outputs:** Confirmation message containing the relative path to the new file. -- **Persistence Strategy:** Permanent write to `./tickets/`. - -## 4. Data & File Contracts -- **State File:** `/temp/pending_ticket_state.json` - - Schema: `{ "original_input": string, "questions": string[], "answers": string[], "draft_content": string, "filename": string, "step": integer }` -- **Output File:** `./tickets/YYYYMMDD-[slug].md` - - Format: Markdown - - Sections: `# Title`, `## Context`, `## Acceptance Criteria`, `## Suggested Affected Files`, `## Technical Constraints`. - -## 5. Failure & Recovery Handling -- **Incomplete Inputs:** If the user fails to answer the 3 questions, the agent must politely restate that metrics are required for high-precision engineering and repeat the questions. -- **Inconsistencies:** If the user’s answers contradict the original dump, the agent must flag the contradiction and ask for a tie-break before drafting. -- **Missing Directory:** If `./tickets/` does not exist during Stage 3, the agent must attempt to create it before writing the file. - -## 6. Final Deliverable Specification -- **Format:** A valid Markdown file in the `./tickets/` folder. -- **Quality Bar:** - - Zero fluff in the Context section. - - All Acceptance Criteria must be binary (pass/fail) or metric-based. - - Filename must strictly follow `YYYYMMDD-slug.md` (e.g., `20240520-auth-refactor.md`). - - No "Status" or "Priority" fields. \ No newline at end of file diff --git a/.agent/workflows/map-impact.md b/.agent/workflows/map-impact.md deleted file mode 100644 index 3ec027d..0000000 --- a/.agent/workflows/map-impact.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -description: Analyzes the codebase to find dependencies and side effects related to a specific ticket. ---- - -# WORKFLOW: Dependency Architect & Blast Radius Analysis - -## 1. High-Level Goal -Perform a deterministic "Blast Radius" analysis for a code change defined in a Jira/Linear-style ticket. The agent will identify direct consumers, side effects, and relevant test suites, then append a structured "Impact Analysis" section to the original ticket file to guide developers and ensure high-velocity execution without regressions. - -## 2. Assumptions & Clarifications -- **Location:** Tickets are stored in the `./tickets/` directory as Markdown files. -- **Code Access:** The agent has full read access to the project root and subdirectories. -- **Scope:** Dependency tracing is limited to "one level deep" (direct imports/references) unless a global configuration or core database schema change is detected. -- **Ambiguity Handling:** If "Suggested Affected Files" are missing from the ticket, the agent will attempt to infer them from the "Acceptance Criteria" logic; if inference is impossible, the agent will halt and request the file list. - -## 3. Stage Breakdown - -### Stage 1: Ticket Parsing & Context Extraction -- **Purpose:** Extract the specific files and logic constraints requiring analysis. -- **Inputs:** A specific ticket filename (e.g., `./tickets/TASK-123.md`). -- **Actions:** - 1. Read the ticket file. - 2. Extract the list of "Suggested Affected Files". - 3. Extract keywords and logic from the "Acceptance Criteria". - 4. Validate that all "Suggested Affected Files" exist in the current codebase. -- **Outputs:** A JSON object containing the target file list and key logic requirements. -- **Persistence Strategy:** Save extracted data to `/temp/context.json`. - -### Stage 2: Recursive Dependency Mapping -- **Purpose:** Identify which external modules rely on the target files. -- **Inputs:** `/temp/context.json`. -- **Actions:** - 1. For each file in the target list, perform a search (e.g., `grep` or AST walk) for import statements or references in the rest of the codebase. - 2. Filter out internal references within the same module (focus on external consumers). - 3. Detect if the change involves shared utilities (e.g., `utils/`, `common/`) or database schemas (e.g., `prisma/schema.prisma`). -- **Outputs:** A list of unique consumer file paths and their specific usage context. -- **Persistence Strategy:** Save findings to `/temp/dependencies.json`. - -### Stage 3: Test Suite Identification -- **Purpose:** Locate the specific test files required to validate the change. -- **Inputs:** `/temp/context.json` and `/temp/dependencies.json`. -- **Actions:** - 1. Search for files following patterns: `[filename].test.ts`, `[filename].spec.js`, or within `__tests__` folders related to affected files. - 2. Identify integration or E2E tests that cover the consumer paths identified in Stage 2. -- **Outputs:** A list of relevant test file paths. -- **Persistence Strategy:** Save findings to `/temp/tests.json`. - -### Stage 4: Risk Hotspot Synthesis -- **Purpose:** Interpret raw dependency data into actionable risk warnings. -- **Inputs:** All files in `/temp/`. -- **Actions:** - 1. Analyze the volume of consumers; if a file has >5 consumers, flag it as a "High Impact Hotspot." - 2. Check for breaking contract changes (e.g., interface modifications) based on the "Acceptance Criteria". - 3. Formulate specific "Risk Hotspot" warnings (e.g., "Changing Auth interface affects 12 files; consider a wrapper."). -- **Outputs:** A structured Markdown-ready report object. -- **Persistence Strategy:** Save final report data to `/temp/final_analysis.json`. - -### Stage 5: Ticket Augmentation & Finalization -- **Purpose:** Update the physical ticket file with findings. -- **Inputs:** Original ticket file and `/temp/final_analysis.json`. -- **Actions:** - 1. Read the current content of the ticket file. - 2. Generate a Markdown section titled `## Impact Analysis (Generated: 2026-01-09)`. - 3. Append the Direct Consumers, Test Coverage, and Risk Hotspots sections. - 4. Write the combined content back to the original file path. -- **Outputs:** Updated Markdown ticket. -- **Persistence Strategy:** None (Final Action). - -## 4. Data & File Contracts -- **State File (`/temp/state.json`):** - `affected_files`: string[] - - `consumers`: { path: string, context: string }[] - - `tests`: string[] - - `risks`: string[] -- **File Format:** All `/temp` files must be valid JSON. -- **Ticket Format:** Standard Markdown. Use `###` for sub-headers in the generated section. - -## 5. Failure & Recovery Handling -- **Missing Ticket:** If the ticket path is invalid, exit immediately with error: "TICKET_NOT_FOUND". -- **Zero Consumers Found:** If no external consumers are found, state "No external dependencies detected" in the report; do not fail. -- **Broken Imports:** If AST parsing fails due to syntax errors in the codebase, fallback to `grep` for string-based matching. -- **Write Permission:** If the ticket file is read-only, output the final Markdown to the console and provide a warning. - -## 6. Final Deliverable Specification -- **Format:** The original ticket file must be modified in-place. -- **Content:** - - **Direct Consumers:** Bulleted list of `[File Path]: [Usage description]`. - - **Test Coverage:** Bulleted list of `[File Path]`. - - **Risk Hotspots:** Clear, one-sentence warnings for high-risk areas. -- **Quality Bar:** No hallucinations. Every file path listed must exist in the repository. No deletions of original ticket content. \ No newline at end of file diff --git a/.agent/workflows/review.md b/.agent/workflows/review.md deleted file mode 100644 index df25445..0000000 --- a/.agent/workflows/review.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -description: Performs a high-intensity, "hostile" technical audit of the provided code. ---- - -# WORKFLOW: HOSTILE TECHNICAL AUDIT & SECURITY REVIEW - -## 1. High-Level Goal -Execute a multi-pass, hyper-critical technical audit of provided source code to identify fatal logic flaws, security vulnerabilities, and architectural debt. The agent acts as a hostile reviewer with a "guilty until proven innocent" mindset, aiming to justify a REJECTED verdict unless the code demonstrates exceptional robustness and simplicity. - -## 2. Assumptions & Clarifications -- **Assumption:** The user will provide either raw code snippets or paths to files within the agent's accessible environment. -- **Assumption:** The agent has access to `/temp/` for multi-stage state persistence. -- **Clarification:** If a "ticket description" or "requirement" is not provided, the agent will infer intent from the code but must flag "Lack of Context" as a potential risk. -- **Clarification:** "Hostile" refers to a rigorous, zero-tolerance standard, not unprofessional language. - -## 3. Stage Breakdown - -### Stage 1: Contextual Ingestion & Dependency Mapping -- **Purpose:** Map the attack surface and understand the logical flow before the audit. -- **Inputs:** Target source code files. -- **Actions:** - Identify all external dependencies and entry points. - - Map data flow from input to storage/output. - - Identify "High-Risk Zones" (e.g., auth logic, DB queries, memory management). -- **Outputs:** A structured map of the code's architecture. -- **Persistence Strategy:** Save `audit_map.json` to `/temp/` containing the file list and identified High-Risk Zones. - -### Stage 2: Security & Logic Stress Test (The "Hostile" Pass) -- **Purpose:** Identify reasons to reject the code based on security and logical integrity. -- **Inputs:** `/temp/audit_map.json` and source code. -- **Actions:** - - Scan for injection, race conditions, and improper state handling. - - Simulate edge cases: null inputs, buffer overflows, and malformed data. - - Evaluate "Silent Failures": Does the code swallow exceptions or fail to log critical errors? -- **Outputs:** List of fatal flaws and security risks. -- **Persistence Strategy:** Save `vulnerabilities.json` to `/temp/`. - -### Stage 3: Performance & Velocity Debt Assessment -- **Purpose:** Evaluate the "Pragmatic Performance" and maintainability of the implementation. -- **Inputs:** Source code and `/temp/vulnerabilities.json`. -- **Actions:** - - Identify redundant API calls or unnecessary allocations. - - Flag "Over-Engineering" (unnecessary abstractions) vs. "Lazy Code" (hardcoded values). - - Identify missing unit test scenarios for identified edge cases. -- **Outputs:** List of optimization debt and missing test scenarios. -- **Persistence Strategy:** Save `debt_and_tests.json` to `/temp/`. - -### Stage 4: Synthesis & Verdict Generation -- **Purpose:** Compile all findings into the final "Hostile Audit" report. -- **Inputs:** `/temp/vulnerabilities.json` and `/temp/debt_and_tests.json`. -- **Actions:** - - Consolidate all findings into the mandated "Response Format." - - Apply the "Burden of Proof" rule: If any Fatal Flaws or Security Risks exist, the verdict is REJECTED. - - Ensure no sycophantic language is present. -- **Outputs:** Final Audit Report. -- **Persistence Strategy:** Final output is delivered to the user; `/temp/` files may be purged. - -## 4. Data & File Contracts -- **Filename:** `/temp/audit_context.json` | **Schema:** `{ "high_risk_zones": [], "entry_points": [] }` -- **Filename:** `/temp/findings.json` | **Schema:** `{ "fatal_flaws": [], "security_risks": [], "debt": [], "missing_tests": [] }` -- **Final Report Format:** Markdown with specific headers: `## πŸ›‘ FATAL FLAWS`, `## ⚠️ SECURITY & VULNERABILITIES`, `## πŸ“‰ VELOCITY DEBT`, `## πŸ§ͺ MISSING TESTS`, and `### VERDICT`. - -## 5. Failure & Recovery Handling -- **Incomplete Input:** If the code is snippet-based and missing context, the agent must assume the worst-case scenario for the missing parts and flag them as "Critical Unknowns." -- **Stage Failure:** If a specific file cannot be parsed, log the error in the `findings.json` and proceed with the remaining files. -- **Clarification:** The agent will NOT ask for clarification mid-audit. It will make a "hostile assumption" and document it as a risk. - -## 6. Final Deliverable Specification -- **Tone:** Senior Security Auditor. Clinical, critical, and direct. -- **Acceptance Criteria:** - No "Good job" or introductory filler. - - Every flaw must include [Why it fails] and [How to fix it]. - - Verdict must be REJECTED unless the code is "solid" (simple, robust, and secure). - - Must identify at least one specific edge case for the "Missing Tests" section. \ No newline at end of file diff --git a/.agent/workflows/work.md b/.agent/workflows/work.md deleted file mode 100644 index 93e0af8..0000000 --- a/.agent/workflows/work.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -description: Work on a ticket ---- - -# WORKFLOW: Automated Feature Implementation and Review Cycle - -## 1. High-Level Goal -The objective of this workflow is to autonomously ingest a task from a local `/tickets` directory, establish a dedicated development environment via Git branching, implement the requested changes with incremental commits, validate the work through an internal review process, and finalize the lifecycle by cleaning up ticket artifacts and seeking user authorization for the final merge. - ---- - -## 2. Assumptions & Clarifications -- **Assumptions:** - - The `/tickets` directory contains one or more files representing tasks (e.g., `.md` or `.txt`). - - The agent has authenticated access to the local Git repository. - - A "Review Workflow" exists as an executable command or internal process. - - The branch naming convention is `feature/[ticket-filename-slug]`. -- **Ambiguities:** - - If multiple tickets exist, the agent will select the one with the earliest "Last Modified" timestamp. - - "Regular commits" are defined as committing after every logically complete file change or functional milestone. - ---- - -## 3. Stage Breakdown - -### Stage 1: Ticket Selection and Branch Initialization -- **Purpose:** Identify the next task and prepare the workspace. -- **Inputs:** Contents of the `/tickets` directory. -- **Actions:** - 1. Scan `/tickets` and select the oldest file. - 2. Parse the ticket content to understand requirements. - 3. Ensure the current working directory is a Git repository. - 4. Create and switch to a new branch: `feature/[ticket-id]`. -- **Outputs:** Active feature branch. -- **Persistence Strategy:** Save `state.json` to `/temp` containing `ticket_path`, `branch_name`, and `start_time`. - -### Stage 2: Implementation and Incremental Committing -- **Purpose:** Execute the technical requirements of the ticket. -- **Inputs:** `/temp/state.json`, Ticket requirements. -- **Actions:** - 1. Modify codebase according to requirements. - 2. For every distinct file change or logical unit of work: - - Run basic syntax checks. - - Execute `git add [file]`. - - Execute `git commit -m "feat: [brief description of change]"` - 3. Repeat until the feature is complete. -- **Outputs:** Committed code changes on the feature branch. -- **Persistence Strategy:** Update `state.json` with `implementation_complete: true` and a list of `modified_files`. - -### Stage 3: Review Workflow Execution -- **Purpose:** Validate the implementation against quality standards. -- **Inputs:** `/temp/state.json`, Modified codebase. -- **Actions:** - 1. Trigger the "Review Workflow" (static analysis, tests, or linter). - 2. If errors are found: - - Log errors to `/temp/review_log.txt`. - - Re-enter Stage 2 to apply fixes and commit. - 3. If review passes: - - Proceed to Stage 4. -- **Outputs:** Review results/logs. -- **Persistence Strategy:** Update `state.json` with `review_passed: true`. - -### Stage 4: Cleanup and User Handoff -- **Purpose:** Finalize the ticket lifecycle and request merge permission. -- **Inputs:** `/temp/state.json`. -- **Actions:** - 1. Delete the ticket file from `/tickets` using the path stored in `state.json`. - 2. Format a summary of changes and a request for merge. -- **Outputs:** Deletion of the ticket file; user-facing summary. -- **Persistence Strategy:** Clear `/temp/state.json` upon successful completion. - ---- - -## 4. Data & File Contracts -- **State File:** `/temp/state.json` - - Format: JSON - - Schema: `{ "ticket_path": string, "branch_name": string, "implementation_complete": boolean, "review_passed": boolean }` -- **Ticket Files:** Located in `/tickets/*` (Markdown or Plain Text). -- **Logs:** `/temp/review_log.txt` (Plain Text) for capturing stderr from review tools. - ---- - -## 5. Failure & Recovery Handling -- **Empty Ticket Directory:** If no files are found in `/tickets`, the agent will output "NO_TICKETS_FOUND" and terminate the workflow. -- **Commit Failures:** If a commit fails (e.g., pre-commit hooks), the agent must resolve the hook violation before retrying the commit. -- **Review Failure Loop:** If the review fails more than 3 times for the same issue, the agent must halt and output a "BLOCKER_REPORT" detailing the persistent errors to the user. -- **State Recovery:** On context reset, the agent must check `/temp/state.json` to resume the workflow from the last recorded stage. - ---- - -## 6. Final Deliverable Specification -- **Final Output:** A clear message to the user in the following format: - > **Task Completed:** [Ticket Name] - > **Branch:** [Branch Name] - > **Changes:** [Brief list of modified files] - > **Review Status:** Passed - > **Cleanup:** Ticket file removed from /tickets. - > **Action Required:** Would you like me to merge [Branch Name] into `main`? (Yes/No) -- **Quality Bar:** Code must be committed with descriptive messages; the ticket file must be successfully deleted; the workspace must be left on the feature branch awaiting the merge command. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 956a946..cfa4f05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env node_modules +docker-compose.override.yml +.agent shared/db-logs shared/db/data shared/db/loga From 4af2690babdbf822776bd58ff9fafcd7a1449059 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 16:10:23 +0100 Subject: [PATCH 02/32] feat: implement branded discord embeds and versioning --- .agent/skills/create-ticket/SKILL.md | 51 ++++++++++++++++++ .agent/skills/review/SKILL.md | 54 +++++++++++++++++++ .../001-refactor-command-validation.md | 30 +++++++++++ .../tickets/002-centralized-error-logging.md | 31 +++++++++++ .../work/tickets/003-modularize-web-server.md | 32 +++++++++++ .../tickets/004-branded-discord-embeds.md | 28 ++++++++++ .../work/tickets/005-refactor-exam-logic.md | 29 ++++++++++ .gitignore | 1 - bot/lib/embeds.ts | 34 +++++++++--- bun.lock | 2 +- package.json | 1 + shared/lib/constants.ts | 6 +++ tsconfig.json | 1 + 13 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 .agent/skills/create-ticket/SKILL.md create mode 100644 .agent/skills/review/SKILL.md create mode 100644 .agent/work/tickets/001-refactor-command-validation.md create mode 100644 .agent/work/tickets/002-centralized-error-logging.md create mode 100644 .agent/work/tickets/003-modularize-web-server.md create mode 100644 .agent/work/tickets/004-branded-discord-embeds.md create mode 100644 .agent/work/tickets/005-refactor-exam-logic.md diff --git a/.agent/skills/create-ticket/SKILL.md b/.agent/skills/create-ticket/SKILL.md new file mode 100644 index 0000000..f854aea --- /dev/null +++ b/.agent/skills/create-ticket/SKILL.md @@ -0,0 +1,51 @@ +--- +name: create-ticket +description: Create a ticket for a task that needs to be worked on. +--- + +# Skill: create-ticket + +## Purpose + +Decompose high-level objectives into "Atomic Tickets" to maximize development velocity and minimize cognitive overhead. + +## Execution Rules + +1. **Directory Check:** Scan `.agent/work/tickets/`. Determine sequence number `NNN`. +2. **Naming:** `.agent/work/tickets/NNN-brief-description.md`. +3. **The Atomic/Velocity Test:** - Max 3 files modified. + - Max 80 lines of logic. + - Must be verifiable via a single command or test suite. + - If it exceeds these, **split it.** +4. **Context Injection:** Include relevant code snippets or interface definitions directly in the ticket to prevent "context hunting." +5. **No Breaking Changes:** If a ticket changes a shared interface, it must include the refactor for consumers or be split into a "Transition" ticket. + +## Ticket Template + +### Context & Goal + +[Why this matters + the specific problem it solves.] + +### Dependencies + +- [e.g., Ticket #NNN] + +### Affected Files + +- `path/to/file_A.ext`: [Specific change description] +- `path/to/file_B.ext`: [Specific change description] + +### Technical Constraints & Strategy + +- [e.g., Implementation: Use the existing X wrapper instead of a new fetch call.] +- [e.g., Constraint: Maintain backward compatibility with Y.] + +### Definition of Done (Binary) + +- [ ] Criterion 1 (e.g., `npm test` passes for `X.test.ts`) +- [ ] Criterion 2 (e.g., UI component renders without hydration errors) +- [ ] Criterion 3 (e.g., API response matches the schema in `types.ts`) + +### New Test Files + +- `path/to/test_A.ext`: [What is being tested] diff --git a/.agent/skills/review/SKILL.md b/.agent/skills/review/SKILL.md new file mode 100644 index 0000000..8481d15 --- /dev/null +++ b/.agent/skills/review/SKILL.md @@ -0,0 +1,54 @@ +--- +name: code-review +description: A "Default-to-Fail" audit of codebase changes. Observation only; no file modifications. +--- + +# Skill: code-review + +## Purpose + +Protect the codebase from "feature creep," technical debt, and weak validation. This skill assumes the latest changes are flawed until they pass a rigorous audit. + +## Execution Rules + +1. **Read-Only Protocol:** This is a diagnostic skill. **Under no circumstances should any files be modified.** Provide feedback only. +2. **Default-to-Fail:** Assume the code is broken or insufficient. The burden of proof lies on the code and its tests. +3. **The Atomic Veto:** - Check the diff. If it exceeds 3 files or 80 lines of logic, **Veto immediately.** + - Reason: "Change exceeds atomic threshold; high risk of cognitive load." +4. **Strictness Audit (Tests):** + - **Veto** if assertions are fuzzy (e.g., `toBeTruthy()`). + - **Veto** if there is no "Red Path" (failure case) test. + - **Veto** if the test is "loose" (e.g., doesn't check specific property values). +5. **Direct Feedback:** No sycophancy. Use "Blockers" for issues and "Verdict: APPROVE" only when the code is bulletproof. + +## Review Template + +### Verdict: [FAIL / APPROVE] + +**Primary Blocker:** [One sentence identifying the biggest reason for rejection.] + +--- + +### 1. Atomic Constraint Check + +- **Files Changed:** [Count] / 3 +- **Logic Lines:** [Count] / 80 +- **Status:** [PASS / FAIL (Veto if FAIL)] + +### 2. Test Strictness Audit + +- **Assertion Quality:** [List specific lines with fuzzy matchers. Demand strict equality.] +- **Failure Coverage:** [Does a test exist for the 'Error/Empty' state? If no, FAIL.] +- **Logic Sync:** [Does the test actually exercise the logic added, or just side effects?] + +### 3. Logic & Resilience + +- **Unchecked States:** [Identify unhandled nulls, undefineds, or missing error catches.] +- **Efficiency:** [Is there a faster path or a redundant operation?] + +### 4. Direct Actionables + +_Note: The reviewer does not apply these. The user/agent must create a ticket or apply fixes manually._ + +1. [Specific fix for Blocker 1] +2. [Specific fix for Blocker 2] diff --git a/.agent/work/tickets/001-refactor-command-validation.md b/.agent/work/tickets/001-refactor-command-validation.md new file mode 100644 index 0000000..9c0c946 --- /dev/null +++ b/.agent/work/tickets/001-refactor-command-validation.md @@ -0,0 +1,30 @@ +### Context & Goal + +Currently, every command manually performs checks like user existence or maintenance mode, or these are hardcoded into the `CommandHandler`. Standardizing these requirements in the command definition itself makes the code cleaner and more declarative. + +### Dependencies + +- None + +### Affected Files + +- `shared/lib/types.ts`: Update `Command` interface to include a optional `requirements` object. +- `bot/lib/handlers/CommandHandler.ts`: Update to read and enforce these requirements. +- `bot/commands/economy/balance.ts`: Refactor to use the new requirements (example). + +### Technical Constraints & Strategy + +- Implementation: Use a standardized `requirements` object in the `Command` interface. +- Requirements could include: `userExists: boolean`, `permissions: string[]`, `devOnly: boolean`. +- Ensure `CommandHandler` provides clear error messages to the user when a requirement fails. + +### Definition of Done (Binary) + +- [ ] `Command` interface updated in `types.ts`. +- [ ] `CommandHandler.ts` enforces requirements before executing command. +- [ ] At least one command (e.g., `balance`) is refactored to use the new system. +- [ ] Clear error embeds are shown to the user when requirements aren't met. + +### New Test Files + +- None (Verification via manual testing of command execution). diff --git a/.agent/work/tickets/002-centralized-error-logging.md b/.agent/work/tickets/002-centralized-error-logging.md new file mode 100644 index 0000000..607c759 --- /dev/null +++ b/.agent/work/tickets/002-centralized-error-logging.md @@ -0,0 +1,31 @@ +### Context & Goal + +The bot currently relies on `console.error` which is hard to track and lacks context. A centralized error logging service will allow for better debugging, persistent error logs, and future integration with services like Sentry or Discord webhooks for alerts. + +### Dependencies + +- None + +### Affected Files + +- `shared/lib/logger.ts`: New file for the unified logger service. +- `bot/lib/handlers/CommandHandler.ts`: Update to use the new logger for command errors. +- `web/src/server.ts`: Update to use the new logger for API and WebSocket errors. + +### Technical Constraints & Strategy + +- Implementation: Create a `Logger` class/object in `shared/lib`. +- Support log levels: `info`, `warn`, `error`, `debug`. +- Errors should capture: timestamp, source (bot/web), error message, and stack trace if available. +- For now, logging to console and a local log file (e.g., `logs/error.log`) is sufficient. + +### Definition of Done (Binary) + +- [ ] `Logger` service implemented in `shared/lib/logger.ts`. +- [ ] Command errors are logged via the new service. +- [ ] Web server errors are logged via the new service. +- [ ] Log output is formatted consistently. + +### New Test Files + +- `shared/lib/logger.test.ts`: Verify logger output and file writing. diff --git a/.agent/work/tickets/003-modularize-web-server.md b/.agent/work/tickets/003-modularize-web-server.md new file mode 100644 index 0000000..45b7db2 --- /dev/null +++ b/.agent/work/tickets/003-modularize-web-server.md @@ -0,0 +1,32 @@ +### Context & Goal + +The current `web/src/server.ts` is a monolithic file with a very long `fetch` handler. This makes it difficult to read and maintain. Modularizing the logic into separate route handlers and middleware-like functions will improve code quality and scalability. + +### Dependencies + +- None + +### Affected Files + +- `web/src/server.ts`: Refactor to use modular handlers. +- `web/src/routes/api.ts`: New file for API route definitions. +- `web/src/routes/static.ts`: New file for static file serving logic. +- `web/src/routes/websocket.ts`: New file for WebSocket event handling. + +### Technical Constraints & Strategy + +- Implementation: Move different responsibilities (API, Static, WS) into separate files. +- The main `serve` configuration should just call these modules. +- Ensure the SPA fallback logic remains intact. + +### Definition of Done (Binary) + +- [ ] `web/src/server.ts` length reduced by at least 50%. +- [ ] API routes moved to dedicated module. +- [ ] Static file serving moved to dedicated module. +- [ ] WebSocket logic moved to dedicated module. +- [ ] Dashboard still loads and functions correctly. + +### New Test Files + +- None (Verification via manual testing of the dashboard). diff --git a/.agent/work/tickets/004-branded-discord-embeds.md b/.agent/work/tickets/004-branded-discord-embeds.md new file mode 100644 index 0000000..951163c --- /dev/null +++ b/.agent/work/tickets/004-branded-discord-embeds.md @@ -0,0 +1,28 @@ +### Context & Goal + +Enhance the user experience by standardizing the look and feel of Discord embeds. Adding consistent branding like a custom footer (with version info) and using the bot's accent color will make the bot feel more professional. + +### Dependencies + +- None + +### Affected Files + +- `bot/lib/embeds.ts`: Update standard embed creators. +- `shared/lib/constants.ts`: Add branding-related constants (colors, footer text). + +### Technical Constraints & Strategy + +- Implementation: Update `createBaseEmbed` and other helpers to automatically include footers and standard colors. +- Use info from `package.json` for versioning in the footer. +- Ensure the changes don't break existing layouts where custom colors might be needed. + +### Definition of Done (Binary) + +- [x] All standard embeds now include a consistent footer. +- [x] Embeds use a predefined brand color by default. +- [x] Version number is automatically pulled for the footer. + +### New Test Files + +- None. diff --git a/.agent/work/tickets/005-refactor-exam-logic.md b/.agent/work/tickets/005-refactor-exam-logic.md new file mode 100644 index 0000000..3c65355 --- /dev/null +++ b/.agent/work/tickets/005-refactor-exam-logic.md @@ -0,0 +1,29 @@ +### Context & Goal + +The `exam` command currently contains a lot of business logic, including reward calculations, timer management, and complex database transactions. Moving this logic to a dedicated `ExamService` will improve testability, maintainability, and keep the command file focused on user interaction. + +### Dependencies + +- None + +### Affected Files + +- `shared/modules/economy/exam.service.ts`: New file for the exam logic. +- `bot/commands/economy/exam.ts`: Refactor to use the new service. + +### Technical Constraints & Strategy + +- Implementation: Create an `ExamService` that handles `getExamStatus`, `takeExam`, and `registerForExam`. +- The command should only handle user input and formatting the response embeds based on the service's result. +- Ensure the Drizzle transactions are correctly handled within the service. + +### Definition of Done (Binary) + +- [ ] `ExamService` implemented with methods for all exam-related operations. +- [ ] `bot/commands/economy/exam.ts` refactored to use the service. +- [ ] Logic is covered by unit tests in a new test file. +- [ ] Manual verification shows the exam command still works as expected. + +### New Test Files + +- `shared/modules/economy/exam.service.test.ts`: Unit tests for reward calculations and state transitions. diff --git a/.gitignore b/.gitignore index cfa4f05..cab42e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .env node_modules docker-compose.override.yml -.agent shared/db-logs shared/db/data shared/db/loga diff --git a/bot/lib/embeds.ts b/bot/lib/embeds.ts index 6e618af..e8645d0 100644 --- a/bot/lib/embeds.ts +++ b/bot/lib/embeds.ts @@ -1,4 +1,15 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; +import { BRANDING } from "@shared/lib/constants"; +import pkg from "../../package.json"; + +/** + * Applies standard branding to an embed. + */ +function applyBranding(embed: EmbedBuilder): EmbedBuilder { + return embed.setFooter({ + text: `${BRANDING.FOOTER_TEXT} v${pkg.version}` + }); +} /** * Creates a standardized error embed. @@ -7,11 +18,13 @@ import { Colors, type ColorResolvable, EmbedBuilder } from "discord.js"; * @returns An EmbedBuilder instance configured as an error. */ export function createErrorEmbed(message: string, title: string = "Error"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`❌ ${title}`) .setDescription(message) .setColor(Colors.Red) .setTimestamp(); + + return applyBranding(embed); } /** @@ -21,11 +34,13 @@ export function createErrorEmbed(message: string, title: string = "Error"): Embe * @returns An EmbedBuilder instance configured as a warning. */ export function createWarningEmbed(message: string, title: string = "Warning"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`⚠️ ${title}`) .setDescription(message) .setColor(Colors.Yellow) .setTimestamp(); + + return applyBranding(embed); } /** @@ -35,11 +50,13 @@ export function createWarningEmbed(message: string, title: string = "Warning"): * @returns An EmbedBuilder instance configured as a success. */ export function createSuccessEmbed(message: string, title: string = "Success"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`βœ… ${title}`) .setDescription(message) .setColor(Colors.Green) .setTimestamp(); + + return applyBranding(embed); } /** @@ -49,11 +66,13 @@ export function createSuccessEmbed(message: string, title: string = "Success"): * @returns An EmbedBuilder instance configured as info. */ export function createInfoEmbed(message: string, title: string = "Info"): EmbedBuilder { - return new EmbedBuilder() + const embed = new EmbedBuilder() .setTitle(`ℹ️ ${title}`) .setDescription(message) .setColor(Colors.Blue) .setTimestamp(); + + return applyBranding(embed); } /** @@ -65,11 +84,12 @@ export function createInfoEmbed(message: string, title: string = "Info"): EmbedB */ export function createBaseEmbed(title?: string, description?: string, color?: ColorResolvable): EmbedBuilder { const embed = new EmbedBuilder() - .setTimestamp(); + .setTimestamp() + .setColor(color ?? BRANDING.COLOR); if (title) embed.setTitle(title); if (description) embed.setDescription(description); - if (color) embed.setColor(color); - return embed; + return applyBranding(embed); } + diff --git a/bun.lock b/bun.lock index f46e892..2fba0dd 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,12 @@ "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "postgres": "^3.4.7", "zod": "^4.1.13", }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.31.7", - "postgres": "^3.4.7", }, "peerDependencies": { "typescript": "^5", diff --git a/package.json b/package.json index 95cceac..8ac85e5 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "app", + "version": "1.0.0", "module": "bot/index.ts", "type": "module", "private": true, diff --git a/shared/lib/constants.ts b/shared/lib/constants.ts index 3d85bad..0df339f 100644 --- a/shared/lib/constants.ts +++ b/shared/lib/constants.ts @@ -85,3 +85,9 @@ export enum TriviaCategory { ANIMALS = 27, ANIME_MANGA = 31, } + +export const BRANDING = { + COLOR: 0x00d4ff as const, + FOOTER_TEXT: 'AuroraBot' as const, +}; + diff --git a/tsconfig.json b/tsconfig.json index 6e79a5d..e4570b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, + "resolveJsonModule": true, "noEmit": true, // Best practices "strict": true, From 915f1bc4ad341ee42ce403f01bad005690163881 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 16:26:27 +0100 Subject: [PATCH 03/32] fix(economy): improve daily cooldown message and consolidate UserError class --- .../completed/004-branded-discord-embeds.md | 28 +++++++++++++++++++ bot/commands/admin/listing.ts | 6 ++-- bot/commands/economy/daily.ts | 9 +++--- bot/commands/economy/exam.ts | 6 ++-- bot/commands/economy/pay.ts | 2 +- bot/commands/economy/trivia.ts | 2 +- bot/commands/inventory/use.ts | 2 +- bot/lib/errors.ts | 18 ------------ .../handlers/ComponentInteractionHandler.ts | 2 +- bot/modules/economy/lootdrop.interaction.ts | 2 +- bot/modules/economy/shop.interaction.ts | 2 +- bot/modules/feedback/feedback.interaction.ts | 2 +- bot/modules/trade/trade.interaction.ts | 2 +- bot/modules/trivia/trivia.interaction.ts | 2 +- bot/modules/user/enrollment.interaction.ts | 2 +- package.json | 2 +- shared/lib/constants.ts | 2 +- shared/modules/economy/economy.service.ts | 2 +- 18 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 .agent/work/completed/004-branded-discord-embeds.md delete mode 100644 bot/lib/errors.ts diff --git a/.agent/work/completed/004-branded-discord-embeds.md b/.agent/work/completed/004-branded-discord-embeds.md new file mode 100644 index 0000000..951163c --- /dev/null +++ b/.agent/work/completed/004-branded-discord-embeds.md @@ -0,0 +1,28 @@ +### Context & Goal + +Enhance the user experience by standardizing the look and feel of Discord embeds. Adding consistent branding like a custom footer (with version info) and using the bot's accent color will make the bot feel more professional. + +### Dependencies + +- None + +### Affected Files + +- `bot/lib/embeds.ts`: Update standard embed creators. +- `shared/lib/constants.ts`: Add branding-related constants (colors, footer text). + +### Technical Constraints & Strategy + +- Implementation: Update `createBaseEmbed` and other helpers to automatically include footers and standard colors. +- Use info from `package.json` for versioning in the footer. +- Ensure the changes don't break existing layouts where custom colors might be needed. + +### Definition of Done (Binary) + +- [x] All standard embeds now include a consistent footer. +- [x] Embeds use a predefined brand color by default. +- [x] Version number is automatically pulled for the footer. + +### New Test Files + +- None. diff --git a/bot/commands/admin/listing.ts b/bot/commands/admin/listing.ts index a9f15b6..7468e18 100644 --- a/bot/commands/admin/listing.ts +++ b/bot/commands/admin/listing.ts @@ -10,7 +10,7 @@ import { } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { createSuccessEmbed, createErrorEmbed, createBaseEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { items } from "@db/schema"; import { ilike, isNotNull, and } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -65,10 +65,10 @@ export const listing = createCommand({ await interaction.editReply({ content: `βœ… Listing for **${item.name}** posted in ${targetChannel}.` }); } catch (error: any) { if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); } else { console.error("Error creating listing:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); } } }, diff --git a/bot/commands/economy/daily.ts b/bot/commands/economy/daily.ts index 3a6b5c4..09a6b19 100644 --- a/bot/commands/economy/daily.ts +++ b/bot/commands/economy/daily.ts @@ -3,13 +3,14 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { economyService } from "@shared/modules/economy/economy.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const daily = createCommand({ data: new SlashCommandBuilder() .setName("daily") .setDescription("Claim your daily reward"), execute: async (interaction) => { + await interaction.deferReply(); try { const result = await economyService.claimDaily(interaction.user.id); @@ -21,14 +22,14 @@ export const daily = createCommand({ ) .setColor("Gold"); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } catch (error: any) { if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); } else { console.error("Error claiming daily:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); } } } diff --git a/bot/commands/economy/exam.ts b/bot/commands/economy/exam.ts index 890d184..926b2aa 100644 --- a/bot/commands/economy/exam.ts +++ b/bot/commands/economy/exam.ts @@ -2,7 +2,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { userTimers, users } from "@db/schema"; import { eq, and, sql } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -195,10 +195,10 @@ export const exam = createCommand({ } catch (error: any) { if (error instanceof UserError) { - await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); } else { console.error("Error in exam command:", error); - await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); + await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); } } } diff --git a/bot/commands/economy/pay.ts b/bot/commands/economy/pay.ts index 2d87899..b51163d 100644 --- a/bot/commands/economy/pay.ts +++ b/bot/commands/economy/pay.ts @@ -5,7 +5,7 @@ import { economyService } from "@shared/modules/economy/economy.service"; import { userService } from "@shared/modules/user/user.service"; import { config } from "@shared/lib/config"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const pay = createCommand({ data: new SlashCommandBuilder() diff --git a/bot/commands/economy/trivia.ts b/bot/commands/economy/trivia.ts index f35a567..3463bfa 100644 --- a/bot/commands/economy/trivia.ts +++ b/bot/commands/economy/trivia.ts @@ -3,7 +3,7 @@ import { SlashCommandBuilder } from "discord.js"; import { triviaService } from "@shared/modules/trivia/trivia.service"; import { getTriviaQuestionView } from "@/modules/trivia/trivia.view"; import { createErrorEmbed } from "@lib/embeds"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { config } from "@shared/lib/config"; import { TriviaCategory } from "@shared/lib/constants"; diff --git a/bot/commands/inventory/use.ts b/bot/commands/inventory/use.ts index 448a346..d425395 100644 --- a/bot/commands/inventory/use.ts +++ b/bot/commands/inventory/use.ts @@ -5,7 +5,7 @@ import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed } from "@lib/embeds"; import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view"; import type { ItemUsageData } from "@shared/lib/types"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { config } from "@shared/lib/config"; export const use = createCommand({ diff --git a/bot/lib/errors.ts b/bot/lib/errors.ts deleted file mode 100644 index 32bce8c..0000000 --- a/bot/lib/errors.ts +++ /dev/null @@ -1,18 +0,0 @@ -export class ApplicationError extends Error { - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } -} - -export class UserError extends ApplicationError { - constructor(message: string) { - super(message); - } -} - -export class SystemError extends ApplicationError { - constructor(message: string) { - super(message); - } -} diff --git a/bot/lib/handlers/ComponentInteractionHandler.ts b/bot/lib/handlers/ComponentInteractionHandler.ts index 4773bee..ac4a7eb 100644 --- a/bot/lib/handlers/ComponentInteractionHandler.ts +++ b/bot/lib/handlers/ComponentInteractionHandler.ts @@ -1,6 +1,6 @@ import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, MessageFlags } from "discord.js"; -import { UserError } from "@lib/errors"; +import { UserError } from "@shared/lib/errors"; import { createErrorEmbed } from "@lib/embeds"; type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction; diff --git a/bot/modules/economy/lootdrop.interaction.ts b/bot/modules/economy/lootdrop.interaction.ts index 72f1893..fae70c3 100644 --- a/bot/modules/economy/lootdrop.interaction.ts +++ b/bot/modules/economy/lootdrop.interaction.ts @@ -1,6 +1,6 @@ import { ButtonInteraction } from "discord.js"; import { lootdropService } from "@shared/modules/economy/lootdrop.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { getLootdropClaimedMessage } from "./lootdrop.view"; export async function handleLootdropInteraction(interaction: ButtonInteraction) { diff --git a/bot/modules/economy/shop.interaction.ts b/bot/modules/economy/shop.interaction.ts index 30b470e..0b397ae 100644 --- a/bot/modules/economy/shop.interaction.ts +++ b/bot/modules/economy/shop.interaction.ts @@ -1,7 +1,7 @@ import { ButtonInteraction, MessageFlags } from "discord.js"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { userService } from "@shared/modules/user/user.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export async function handleShopInteraction(interaction: ButtonInteraction) { if (!interaction.customId.startsWith("shop_buy_")) return; diff --git a/bot/modules/feedback/feedback.interaction.ts b/bot/modules/feedback/feedback.interaction.ts index 4ccfc7e..4c67d6d 100644 --- a/bot/modules/feedback/feedback.interaction.ts +++ b/bot/modules/feedback/feedback.interaction.ts @@ -4,7 +4,7 @@ import { config } from "@shared/lib/config"; import { AuroraClient } from "@/lib/BotClient"; import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view"; import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export const handleFeedbackInteraction = async (interaction: Interaction) => { // Handle select menu for choosing feedback type diff --git a/bot/modules/trade/trade.interaction.ts b/bot/modules/trade/trade.interaction.ts index ea4a45b..acdc537 100644 --- a/bot/modules/trade/trade.interaction.ts +++ b/bot/modules/trade/trade.interaction.ts @@ -10,7 +10,7 @@ import { import { tradeService } from "@shared/modules/trade/trade.service"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; -import { UserError } from "@lib/errors"; +import { UserError } from "@shared/lib/errors"; import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; diff --git a/bot/modules/trivia/trivia.interaction.ts b/bot/modules/trivia/trivia.interaction.ts index 5bf5006..69de286 100644 --- a/bot/modules/trivia/trivia.interaction.ts +++ b/bot/modules/trivia/trivia.interaction.ts @@ -1,7 +1,7 @@ import { ButtonInteraction } from "discord.js"; import { triviaService } from "@shared/modules/trivia/trivia.service"; import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; export async function handleTriviaInteraction(interaction: ButtonInteraction) { const parts = interaction.customId.split('_'); diff --git a/bot/modules/user/enrollment.interaction.ts b/bot/modules/user/enrollment.interaction.ts index b15b0aa..bec444a 100644 --- a/bot/modules/user/enrollment.interaction.ts +++ b/bot/modules/user/enrollment.interaction.ts @@ -3,7 +3,7 @@ import { config } from "@shared/lib/config"; import { getEnrollmentSuccessMessage } from "./enrollment.view"; import { classService } from "@shared/modules/class/class.service"; import { userService } from "@shared/modules/user/user.service"; -import { UserError } from "@/lib/errors"; +import { UserError } from "@shared/lib/errors"; import { sendWebhookMessage } from "@/lib/webhookUtils"; export async function handleEnrollmentInteraction(interaction: ButtonInteraction) { diff --git a/package.json b/package.json index 8ac85e5..f4225b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app", - "version": "1.0.0", + "version": "1.1.3", "module": "bot/index.ts", "type": "module", "private": true, diff --git a/shared/lib/constants.ts b/shared/lib/constants.ts index 0df339f..653d187 100644 --- a/shared/lib/constants.ts +++ b/shared/lib/constants.ts @@ -88,6 +88,6 @@ export enum TriviaCategory { export const BRANDING = { COLOR: 0x00d4ff as const, - FOOTER_TEXT: 'AuroraBot' as const, + FOOTER_TEXT: 'Aurora' as const, }; diff --git a/shared/modules/economy/economy.service.ts b/shared/modules/economy/economy.service.ts index b093401..6d026e4 100644 --- a/shared/modules/economy/economy.service.ts +++ b/shared/modules/economy/economy.service.ts @@ -87,7 +87,7 @@ export const economyService = { }); if (cooldown && cooldown.expiresAt > now) { - throw new UserError(`Daily already claimed today. Next claim `); + throw new UserError(`You have already claimed your daily reward today.\nNext claim available: ()`); } // Get user for streak logic From f79ee6fbc7ea20ded95f95d3d413005b1a66593b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 16:27:49 +0100 Subject: [PATCH 04/32] refactor: remove completed ticket file --- .../completed/004-branded-discord-embeds.md | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 .agent/work/completed/004-branded-discord-embeds.md diff --git a/.agent/work/completed/004-branded-discord-embeds.md b/.agent/work/completed/004-branded-discord-embeds.md deleted file mode 100644 index 951163c..0000000 --- a/.agent/work/completed/004-branded-discord-embeds.md +++ /dev/null @@ -1,28 +0,0 @@ -### Context & Goal - -Enhance the user experience by standardizing the look and feel of Discord embeds. Adding consistent branding like a custom footer (with version info) and using the bot's accent color will make the bot feel more professional. - -### Dependencies - -- None - -### Affected Files - -- `bot/lib/embeds.ts`: Update standard embed creators. -- `shared/lib/constants.ts`: Add branding-related constants (colors, footer text). - -### Technical Constraints & Strategy - -- Implementation: Update `createBaseEmbed` and other helpers to automatically include footers and standard colors. -- Use info from `package.json` for versioning in the footer. -- Ensure the changes don't break existing layouts where custom colors might be needed. - -### Definition of Done (Binary) - -- [x] All standard embeds now include a consistent footer. -- [x] Embeds use a predefined brand color by default. -- [x] Version number is automatically pulled for the footer. - -### New Test Files - -- None. From 54944283a30bdf40596e89b8523c8df4e64e4c85 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 17:58:28 +0100 Subject: [PATCH 05/32] feat: implement centralized logger with file persistence --- shared/lib/logger.test.ts | 118 +++++++++++++++++++++++++++ shared/lib/logger.ts | 162 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 shared/lib/logger.test.ts create mode 100644 shared/lib/logger.ts diff --git a/shared/lib/logger.test.ts b/shared/lib/logger.test.ts new file mode 100644 index 0000000..71ce86b --- /dev/null +++ b/shared/lib/logger.test.ts @@ -0,0 +1,118 @@ +import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test"; +import { logger } from "./logger"; +import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +describe("Logger", () => { + const logDir = join(process.cwd(), "logs"); + const logFile = join(logDir, "error.log"); + + beforeAll(() => { + // Cleanup if exists + try { + if (existsSync(logFile)) unlinkSync(logFile); + } catch (e) {} + }); + + test("should log info messages to console with correct format", () => { + const spy = spyOn(console, "log"); + const message = "Formatting test"; + logger.info("system", message); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + // Strict regex check for ISO timestamp and format + const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/; + expect(callArgs).toMatch(regex); + } + spy.mockRestore(); + }); + + test("should write error logs to file with stack trace", async () => { + const errorMessage = "Test error message"; + const testError = new Error("Source error"); + + logger.error("system", errorMessage, testError); + + // Polling for file write instead of fixed timeout + let content = ""; + for (let i = 0; i < 20; i++) { + if (existsSync(logFile)) { + content = readFileSync(logFile, "utf-8"); + if (content.includes("Source error")) break; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + + expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/); + expect(content).toContain("Stack Trace:"); + expect(content).toContain("Error: Source error"); + expect(content).toContain("logger.test.ts"); + }); + + test("should handle log directory creation failures gracefully", async () => { + const consoleSpy = spyOn(console, "error"); + + // We trigger an error by trying to use a path that is a file where a directory should be + const triggerFile = join(process.cwd(), "logs_fail_trigger"); + + try { + writeFileSync(triggerFile, "not a directory"); + + // Manually override paths for this test instance + const originalLogDir = (logger as any).logDir; + const originalLogPath = (logger as any).errorLogPath; + + (logger as any).logDir = triggerFile; + (logger as any).errorLogPath = join(triggerFile, "error.log"); + (logger as any).initialized = false; + + logger.error("system", "This should fail directory creation"); + + // Wait for async initialization attempt + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy.mock.calls.some(call => + String(call[0]).includes("Failed to initialize logger directory") + )).toBe(true); + + // Reset logger state + (logger as any).logDir = originalLogDir; + (logger as any).errorLogPath = originalLogPath; + (logger as any).initialized = false; + } finally { + if (existsSync(triggerFile)) unlinkSync(triggerFile); + consoleSpy.mockRestore(); + } + }); + + test("should include complex data objects in logs", () => { + const spy = spyOn(console, "log"); + const data = { userId: "123", tags: ["test"] }; + logger.info("bot", "Message with data", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`); + } + spy.mockRestore(); + }); + + test("should handle circular references in data objects", () => { + const spy = spyOn(console, "log"); + const data: any = { name: "circular" }; + data.self = data; + + logger.info("bot", "Circular test", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toContain("[Circular]"); + spy.mockRestore(); + }); +}); diff --git a/shared/lib/logger.ts b/shared/lib/logger.ts new file mode 100644 index 0000000..ed0cf61 --- /dev/null +++ b/shared/lib/logger.ts @@ -0,0 +1,162 @@ +import { join, resolve } from "path"; +import { appendFile, mkdir, stat } from "fs/promises"; +import { existsSync } from "fs"; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const LogLevelNames = { + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", +}; + +export type LogSource = "bot" | "web" | "shared" | "system"; + +export interface LogEntry { + timestamp: string; + level: string; + source: LogSource; + message: string; + data?: any; + stack?: string; +} + +class Logger { + private logDir: string; + private errorLogPath: string; + private initialized: boolean = false; + private initPromise: Promise | null = null; + + constructor() { + // Use resolve with __dirname or process.cwd() but make it more robust + // Since this is in shared/lib/, we can try to find the project root + // For now, let's stick to a resolved path from process.cwd() or a safer alternative + this.logDir = resolve(process.cwd(), "logs"); + this.errorLogPath = join(this.logDir, "error.log"); + } + + private async ensureInitialized() { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + try { + await mkdir(this.logDir, { recursive: true }); + this.initialized = true; + } catch (err: any) { + if (err.code === "EEXIST" || err.code === "ENOTDIR") { + try { + const stats = await stat(this.logDir); + if (stats.isDirectory()) { + this.initialized = true; + return; + } + } catch (statErr) {} + } + console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err); + } finally { + this.initPromise = null; + } + })(); + + return this.initPromise; + } + + private safeStringify(data: any): string { + try { + return JSON.stringify(data); + } catch (err) { + const seen = new WeakSet(); + return JSON.stringify(data, (key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }); + } + } + + private formatMessage(entry: LogEntry): string { + const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : ""; + const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : ""; + return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`; + } + + private async writeToErrorLog(formatted: string) { + await this.ensureInitialized(); + try { + await appendFile(this.errorLogPath, formatted + "\n"); + } catch (err) { + console.error("[SYSTEM] Failed to write to error log file:", err); + } + } + + private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) { + const timestamp = new Date().toISOString(); + const levelName = LogLevelNames[level]; + + const entry: LogEntry = { + timestamp, + level: levelName, + source, + message, + }; + + if (level === LogLevel.ERROR && errorOrData instanceof Error) { + entry.stack = errorOrData.stack; + entry.message = `${message}: ${errorOrData.message}`; + } else if (errorOrData !== undefined) { + entry.data = errorOrData; + } + + const formatted = this.formatMessage(entry); + + // Print to console + switch (level) { + case LogLevel.DEBUG: + console.debug(formatted); + break; + case LogLevel.INFO: + console.log(formatted); + break; + case LogLevel.WARN: + console.warn(formatted); + break; + case LogLevel.ERROR: + console.error(formatted); + break; + } + + // Persistent error logging + if (level === LogLevel.ERROR) { + this.writeToErrorLog(formatted).catch(() => { + // Silently fail to avoid infinite loops + }); + } + } + + debug(source: LogSource, message: string, data?: any) { + this.log(LogLevel.DEBUG, source, message, data); + } + + info(source: LogSource, message: string, data?: any) { + this.log(LogLevel.INFO, source, message, data); + } + + warn(source: LogSource, message: string, data?: any) { + this.log(LogLevel.WARN, source, message, data); + } + + error(source: LogSource, message: string, error?: any) { + this.log(LogLevel.ERROR, source, message, error); + } +} + +export const logger = new Logger(); From 1e20a5a7a04afe9e35e5164e431c52c12886c45c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 17:58:28 +0100 Subject: [PATCH 06/32] refactor: migrate bot handlers to centralized logger --- bot/lib/handlers/AutocompleteHandler.ts | 3 ++- bot/lib/handlers/CommandHandler.ts | 7 ++++--- bot/lib/handlers/ComponentInteractionHandler.ts | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/lib/handlers/AutocompleteHandler.ts b/bot/lib/handlers/AutocompleteHandler.ts index 61b5132..d5a0498 100644 --- a/bot/lib/handlers/AutocompleteHandler.ts +++ b/bot/lib/handlers/AutocompleteHandler.ts @@ -1,5 +1,6 @@ import { AutocompleteInteraction } from "discord.js"; import { AuroraClient } from "@/lib/BotClient"; +import { logger } from "@shared/lib/logger"; /** @@ -16,7 +17,7 @@ export class AutocompleteHandler { try { await command.autocomplete(interaction); } catch (error) { - console.error(`Error handling autocomplete for ${interaction.commandName}:`, error); + logger.error("bot", `Error handling autocomplete for ${interaction.commandName}`, error); } } } diff --git a/bot/lib/handlers/CommandHandler.ts b/bot/lib/handlers/CommandHandler.ts index 3fc8647..eeb4b08 100644 --- a/bot/lib/handlers/CommandHandler.ts +++ b/bot/lib/handlers/CommandHandler.ts @@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; import { AuroraClient } from "@/lib/BotClient"; import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed } from "@lib/embeds"; +import { logger } from "@shared/lib/logger"; /** @@ -13,7 +14,7 @@ export class CommandHandler { const command = AuroraClient.commands.get(interaction.commandName); if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); + logger.error("bot", `No command matching ${interaction.commandName} was found.`); return; } @@ -28,14 +29,14 @@ export class CommandHandler { try { await userService.getOrCreateUser(interaction.user.id, interaction.user.username); } catch (error) { - console.error("Failed to ensure user exists:", error); + logger.error("bot", "Failed to ensure user exists", error); } try { await command.execute(interaction); AuroraClient.lastCommandTimestamp = Date.now(); } catch (error) { - console.error(String(error)); + logger.error("bot", `Error executing command ${interaction.commandName}`, error); const errorEmbed = createErrorEmbed('There was an error while executing this command!'); if (interaction.replied || interaction.deferred) { diff --git a/bot/lib/handlers/ComponentInteractionHandler.ts b/bot/lib/handlers/ComponentInteractionHandler.ts index ac4a7eb..f99d127 100644 --- a/bot/lib/handlers/ComponentInteractionHandler.ts +++ b/bot/lib/handlers/ComponentInteractionHandler.ts @@ -2,6 +2,7 @@ import { ButtonInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, import { UserError } from "@shared/lib/errors"; import { createErrorEmbed } from "@lib/embeds"; +import { logger } from "@shared/lib/logger"; type ComponentInteraction = ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction; @@ -28,7 +29,7 @@ export class ComponentInteractionHandler { return; } } else { - console.error(`Handler method ${route.method} not found in module`); + logger.error("bot", `Handler method ${route.method} not found in module`); } } } @@ -52,7 +53,7 @@ export class ComponentInteractionHandler { // Log system errors (non-user errors) for debugging if (!isUserError) { - console.error(`Error in ${handlerName}:`, error); + logger.error("bot", `Error in ${handlerName}`, error); } const errorEmbed = createErrorEmbed(errorMessage); @@ -72,7 +73,7 @@ export class ComponentInteractionHandler { } } catch (replyError) { // If we can't send a reply, log it - console.error(`Failed to send error response in ${handlerName}:`, replyError); + logger.error("bot", `Failed to send error response in ${handlerName}`, replyError); } } } From c7730b93552b99b7bea1213e228b18eb7d84692c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 17:58:28 +0100 Subject: [PATCH 07/32] refactor: migrate web server to centralized logger --- web/src/server.ts | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/web/src/server.ts b/web/src/server.ts index 90809aa..9b666a2 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -6,6 +6,7 @@ import { serve, spawn, type Subprocess } from "bun"; import { join, resolve, dirname } from "path"; +import { logger } from "@shared/lib/logger"; export interface WebServerConfig { port?: number; @@ -39,7 +40,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise= MAX_CONNECTIONS) { - console.warn(`⚠️ [WS] Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); + logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); return new Response("Connection limit reached", { status: 429 }); } @@ -94,7 +95,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise { @@ -308,7 +309,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise MAX_PAYLOAD_BYTES) { - console.error("❌ [WS] Payload exceeded maximum limit"); + logger.error("web", "Payload exceeded maximum limit"); return; } @@ -328,7 +329,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise(result: PromiseSettledResult, defaultValue: T, name: string): T => { if (result.status === 'fulfilled') return result.value; - console.error(`Failed to fetch ${name}:`, result.reason); + logger.error("web", `Failed to fetch ${name}`, result.reason); return defaultValue; }; @@ -403,7 +404,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise Date: Wed, 14 Jan 2026 18:10:13 +0100 Subject: [PATCH 08/32] feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage --- bot/commands/economy/exam.ts | 172 ++----------- shared/modules/economy/exam.service.test.ts | 237 ++++++++++++++++++ shared/modules/economy/exam.service.ts | 262 ++++++++++++++++++++ 3 files changed, 520 insertions(+), 151 deletions(-) create mode 100644 shared/modules/economy/exam.service.test.ts create mode 100644 shared/modules/economy/exam.service.ts diff --git a/bot/commands/economy/exam.ts b/bot/commands/economy/exam.ts index 926b2aa..f7bfde2 100644 --- a/bot/commands/economy/exam.ts +++ b/bot/commands/economy/exam.ts @@ -1,21 +1,7 @@ import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; -import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; -import { UserError } from "@shared/lib/errors"; -import { userTimers, users } from "@db/schema"; -import { eq, and, sql } from "drizzle-orm"; -import { DrizzleClient } from "@shared/db/DrizzleClient"; -import { config } from "@shared/lib/config"; -import { TimerType } from "@shared/lib/constants"; - -const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; -const EXAM_TIMER_KEY = 'default'; - -interface ExamMetadata { - examDay: number; - lastXp: string; -} +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -25,105 +11,42 @@ export const exam = createCommand({ .setDescription("Take your weekly exam to earn rewards based on your XP progress."), execute: async (interaction) => { await interaction.deferReply(); - const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); - if (!user) { - await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] }); - return; - } - const now = new Date(); - const currentDay = now.getDay(); try { - // 1. Fetch existing timer/exam data - const timer = await DrizzleClient.query.userTimers.findFirst({ - where: and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - ) - }); + // First, try to take the exam or check status + const result = await examService.takeExam(interaction.user.id); - // 2. First Run Logic - if (!timer) { - // Set exam day to today - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + 7); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const metadata: ExamMetadata = { - examDay: currentDay, - lastXp: (user.xp ?? 0n).toString() - }; - - await DrizzleClient.insert(userTimers).values({ - userId: user.id, - type: EXAM_TIMER_TYPE, - key: EXAM_TIMER_KEY, - expiresAt: nextExamDate, - metadata: metadata - }); + if (result.status === ExamStatus.NOT_REGISTERED) { + // Register the user + const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); + const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); await interaction.editReply({ embeds: [createSuccessEmbed( - `You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + - `Come back on () to take your first exam!`, + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` + + `Come back on () to take your first exam!`, "Exam Registration Successful" )] }); return; } - const metadata = timer.metadata as unknown as ExamMetadata; - const examDay = metadata.examDay; - - // 3. Cooldown Check - const expiresAt = new Date(timer.expiresAt); - expiresAt.setHours(0, 0, 0, 0); - - if (now < expiresAt) { - // Calculate time remaining - const timestamp = Math.floor(expiresAt.getTime() / 1000); + const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000); + if (result.status === ExamStatus.COOLDOWN) { await interaction.editReply({ embeds: [createErrorEmbed( `You have already taken your exam for this week (or are waiting for your first week to pass).\n` + - `Next exam available: ()` + `Next exam available: ()` )] }); return; } - // 4. Day Check - if (currentDay !== examDay) { - // Calculate next correct exam day to correct the schedule - let daysUntil = (examDay - currentDay + 7) % 7; - if (daysUntil === 0) daysUntil = 7; - - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + daysUntil); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const newMetadata: ExamMetadata = { - examDay: examDay, - lastXp: (user.xp ?? 0n).toString() - }; - - await DrizzleClient.update(userTimers) - .set({ - expiresAt: nextExamDate, - metadata: newMetadata - }) - .where(and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - )); - + if (result.status === ExamStatus.MISSED) { await interaction.editReply({ embeds: [createErrorEmbed( - `You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` + `You verify your attendance but score a **0**.\n` + `Your next exam opportunity is: ()`, "Exam Failed" @@ -132,74 +55,21 @@ export const exam = createCommand({ return; } - // 5. Reward Calculation - const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case - const currentXp = user.xp ?? 0n; - const diff = currentXp - lastXp; - - // Calculate Reward - const multMin = config.economy.exam.multMin; - const multMax = config.economy.exam.multMax; - const multiplier = Math.random() * (multMax - multMin) + multMin; - - // Allow negative reward? existing description implies "difference", usually gain. - // If diff is negative (lost XP?), reward might be 0. - let reward = 0n; - if (diff > 0n) { - reward = BigInt(Math.floor(Number(diff) * multiplier)); - } - - // 6. Update State - const nextExamDate = new Date(now); - nextExamDate.setDate(now.getDate() + 7); - nextExamDate.setHours(0, 0, 0, 0); - const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); - - const newMetadata: ExamMetadata = { - examDay: examDay, - lastXp: currentXp.toString() - }; - - await DrizzleClient.transaction(async (tx) => { - // Update Timer - await tx.update(userTimers) - .set({ - expiresAt: nextExamDate, - metadata: newMetadata - }) - .where(and( - eq(userTimers.userId, user.id), - eq(userTimers.type, EXAM_TIMER_TYPE), - eq(userTimers.key, EXAM_TIMER_KEY) - )); - - // Add Currency - if (reward > 0n) { - await tx.update(users) - .set({ - balance: sql`${users.balance} + ${reward}` - }) - .where(eq(users.id, user.id)); - } - }); - + // If it reached here with AVAILABLE, it means they passed await interaction.editReply({ embeds: [createSuccessEmbed( - `**XP Gained:** ${diff.toString()}\n` + - `**Multiplier:** x${multiplier.toFixed(2)}\n` + - `**Reward:** ${reward.toString()} Currency\n\n` + + `**XP Gained:** ${result.xpDiff?.toString()}\n` + + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` + + `**Reward:** ${result.reward?.toString()} Currency\n\n` + `See you next week: `, "Exam Passed!" )] }); } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error in exam command:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); - } + console.error("Error in exam command:", error); + await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] }); } } }); + diff --git a/shared/modules/economy/exam.service.test.ts b/shared/modules/economy/exam.service.test.ts new file mode 100644 index 0000000..692955b --- /dev/null +++ b/shared/modules/economy/exam.service.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; +import { users, userTimers, transactions } from "@db/schema"; + +// Define mock functions +const mockFindFirst = mock(); +const mockInsert = mock(); +const mockUpdate = mock(); +const mockValues = mock(); +const mockReturning = mock(); +const mockSet = mock(); +const mockWhere = mock(); + +// Chainable mock setup +mockInsert.mockReturnValue({ values: mockValues }); +mockValues.mockReturnValue({ returning: mockReturning }); +mockUpdate.mockReturnValue({ set: mockSet }); +mockSet.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ returning: mockReturning }); + +// Mock DrizzleClient +mock.module("@shared/db/DrizzleClient", () => { + const createMockTx = () => ({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + + return { + DrizzleClient: { + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + transaction: async (cb: any) => { + return cb(createMockTx()); + } + }, + }; +}); + +// Mock withTransaction +mock.module("@/lib/db", () => ({ + withTransaction: async (cb: any, tx?: any) => { + if (tx) return cb(tx); + return cb({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + } +})); + +// Mock Config +mock.module("@shared/lib/config", () => ({ + config: { + economy: { + exam: { + multMin: 1.0, + multMax: 2.0, + } + } + } +})); + +// Mock User Service +mock.module("@shared/modules/user/user.service", () => ({ + userService: { + getOrCreateUser: mock() + } +})); + +// Mock Dashboard Service +mock.module("@shared/modules/dashboard/dashboard.service", () => ({ + dashboardService: { + recordEvent: mock() + } +})); + +describe("ExamService", () => { + beforeEach(() => { + mockFindFirst.mockReset(); + mockInsert.mockClear(); + mockUpdate.mockClear(); + mockValues.mockClear(); + mockReturning.mockClear(); + mockSet.mockClear(); + mockWhere.mockClear(); + }); + + describe("getExamStatus", () => { + it("should return NOT_REGISTERED if no timer exists", async () => { + mockFindFirst.mockResolvedValue(undefined); + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should return COOLDOWN if now < expiresAt", async () => { + const now = new Date("2024-01-10T12:00:00Z"); + setSystemTime(now); + const future = new Date("2024-01-11T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: future, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.COOLDOWN); + expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0)); + }); + + it("should return MISSED if it is the wrong day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.MISSED); + expect(status.examDay).toBe(3); + }); + + it("should return AVAILABLE if it is the correct day", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.AVAILABLE); + expect(status.examDay).toBe(3); + expect(status.lastXp).toBe(100n); + }); + }); + + describe("registerForExam", () => { + it("should create user and timer correctly", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + + const { userService } = await import("@shared/modules/user/user.service"); + (userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n }); + + const result = await examService.registerForExam("1", "testuser"); + + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + expect(result.examDay).toBe(1); + + expect(mockInsert).toHaveBeenCalledWith(userTimers); + expect(mockInsert).toHaveBeenCalledTimes(1); + }); + }); + + describe("takeExam", () => { + it("should return NOT_REGISTERED if not registered", async () => { + mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check + .mockResolvedValueOnce(undefined); // timer check + + const result = await examService.takeExam("1"); + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should handle missed exam and schedule for next exam day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.MISSED); + expect(result.examDay).toBe(3); + + // Should set next exam to next Wednesday + // Monday (1) + 2 days = Wednesday (3) + const expected = new Date("2024-01-17T00:00:00Z"); + expect(result.nextExamAt!.getTime()).toBe(expected.getTime()); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + }); + + it("should calculate rewards and update state when passed", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.AVAILABLE); + expect(result.xpDiff).toBe(500n); + // Multiplier is between 1.0 and 2.0 based on mock config + expect(result.multiplier).toBeGreaterThanOrEqual(1.0); + expect(result.multiplier).toBeLessThanOrEqual(2.0); + expect(result.reward).toBeGreaterThanOrEqual(500n); + expect(result.reward).toBeLessThanOrEqual(1000n); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + expect(mockUpdate).toHaveBeenCalledWith(users); + + // Verify transaction + expect(mockInsert).toHaveBeenCalledWith(transactions); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + amount: result.reward, + userId: 1n, + type: expect.anything() + })); + }); + }); +}); diff --git a/shared/modules/economy/exam.service.ts b/shared/modules/economy/exam.service.ts new file mode 100644 index 0000000..5f01fe1 --- /dev/null +++ b/shared/modules/economy/exam.service.ts @@ -0,0 +1,262 @@ +import { users, userTimers, transactions } from "@db/schema"; +import { eq, and, sql } from "drizzle-orm"; +import { TimerType, TransactionType } from "@shared/lib/constants"; +import { config } from "@shared/lib/config"; +import { withTransaction } from "@/lib/db"; +import type { Transaction } from "@shared/lib/types"; +import { UserError } from "@shared/lib/errors"; + +const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; +const EXAM_TIMER_KEY = 'default'; + +export interface ExamMetadata { + examDay: number; + lastXp: string; +} + +export enum ExamStatus { + NOT_REGISTERED = 'NOT_REGISTERED', + COOLDOWN = 'COOLDOWN', + MISSED = 'MISSED', + AVAILABLE = 'AVAILABLE', +} + +export interface ExamActionResult { + status: ExamStatus; + nextExamAt?: Date; + reward?: bigint; + xpDiff?: bigint; + multiplier?: number; + examDay?: number; +} + +export const examService = { + /** + * Get the current exam status for a user. + */ + async getExamStatus(userId: string, tx?: Transaction) { + return await withTransaction(async (txFn) => { + const timer = await txFn.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + ) + }); + + if (!timer) { + return { status: ExamStatus.NOT_REGISTERED }; + } + + const now = new Date(); + const expiresAt = new Date(timer.expiresAt); + expiresAt.setHours(0, 0, 0, 0); + + if (now < expiresAt) { + return { + status: ExamStatus.COOLDOWN, + nextExamAt: expiresAt + }; + } + + const metadata = timer.metadata as unknown as ExamMetadata; + const currentDay = now.getDay(); + + if (currentDay !== metadata.examDay) { + return { + status: ExamStatus.MISSED, + nextExamAt: expiresAt, + examDay: metadata.examDay + }; + } + + return { + status: ExamStatus.AVAILABLE, + examDay: metadata.examDay, + lastXp: BigInt(metadata.lastXp || "0") + }; + }, tx); + }, + + /** + * Register a user for the first time. + */ + async registerForExam(userId: string, username: string, tx?: Transaction): Promise { + return await withTransaction(async (txFn) => { + // Ensure user exists + const { userService } = await import("@shared/modules/user/user.service"); + const user = await userService.getOrCreateUser(userId, username, txFn); + if (!user) throw new Error("Failed to get or create user."); + + const now = new Date(); + const currentDay = now.getDay(); + + // Set next exam to next week + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + 7); + nextExamDate.setHours(0, 0, 0, 0); + + const metadata: ExamMetadata = { + examDay: currentDay, + lastXp: (user.xp ?? 0n).toString() + }; + + await txFn.insert(userTimers).values({ + userId: BigInt(userId), + type: EXAM_TIMER_TYPE, + key: EXAM_TIMER_KEY, + expiresAt: nextExamDate, + metadata: metadata + }); + + return { + status: ExamStatus.NOT_REGISTERED, + nextExamAt: nextExamDate, + examDay: currentDay + }; + }, tx); + }, + + /** + * Take the exam. Handles missed exams and reward calculations. + */ + async takeExam(userId: string, tx?: Transaction): Promise { + return await withTransaction(async (txFn) => { + const user = await txFn.query.users.findFirst({ + where: eq(users.id, BigInt(userId)) + }); + + if (!user) throw new Error("User not found"); + + const timer = await txFn.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + ) + }); + + if (!timer) { + return { status: ExamStatus.NOT_REGISTERED }; + } + + const now = new Date(); + const expiresAt = new Date(timer.expiresAt); + expiresAt.setHours(0, 0, 0, 0); + + if (now < expiresAt) { + return { + status: ExamStatus.COOLDOWN, + nextExamAt: expiresAt + }; + } + + const metadata = timer.metadata as unknown as ExamMetadata; + const examDay = metadata.examDay; + const currentDay = now.getDay(); + + if (currentDay !== examDay) { + // Missed exam logic + let daysUntil = (examDay - currentDay + 7) % 7; + if (daysUntil === 0) daysUntil = 7; + + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + daysUntil); + nextExamDate.setHours(0, 0, 0, 0); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: (user.xp ?? 0n).toString() + }; + + await txFn.update(userTimers) + .set({ + expiresAt: nextExamDate, + metadata: newMetadata + }) + .where(and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + )); + + return { + status: ExamStatus.MISSED, + nextExamAt: nextExamDate, + examDay: examDay + }; + } + + // Reward Calculation + const lastXp = BigInt(metadata.lastXp || "0"); + const currentXp = user.xp ?? 0n; + const diff = currentXp - lastXp; + + const multMin = config.economy.exam.multMin; + const multMax = config.economy.exam.multMax; + const multiplier = Math.random() * (multMax - multMin) + multMin; + + let reward = 0n; + if (diff > 0n) { + // Use scaled BigInt arithmetic to avoid precision loss with large XP values + const scaledMultiplier = BigInt(Math.round(multiplier * 10000)); + reward = (diff * scaledMultiplier) / 10000n; + } + + const nextExamDate = new Date(now); + nextExamDate.setDate(now.getDate() + 7); + nextExamDate.setHours(0, 0, 0, 0); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: currentXp.toString() + }; + + // Update Timer + await txFn.update(userTimers) + .set({ + expiresAt: nextExamDate, + metadata: newMetadata + }) + .where(and( + eq(userTimers.userId, BigInt(userId)), + eq(userTimers.type, EXAM_TIMER_TYPE), + eq(userTimers.key, EXAM_TIMER_KEY) + )); + + // Add Currency + if (reward > 0n) { + await txFn.update(users) + .set({ + balance: sql`${users.balance} + ${reward}` + }) + .where(eq(users.id, BigInt(userId))); + + // Add Transaction Record + await txFn.insert(transactions).values({ + userId: BigInt(userId), + amount: reward, + type: TransactionType.EXAM_REWARD, + description: `Weekly exam reward (XP Diff: ${diff})`, + }); + } + + // Record dashboard event + const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); + await dashboardService.recordEvent({ + type: 'success', + message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`, + icon: 'πŸŽ“' + }); + + return { + status: ExamStatus.AVAILABLE, + nextExamAt: nextExamDate, + reward, + xpDiff: diff, + multiplier, + examDay + }; + }, tx); + } +}; From 194a032c7f8fbb7e7f9f3a3b736547d4a450eb35 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 14 Jan 2026 18:10:31 +0100 Subject: [PATCH 09/32] chore(cleanup): remove completed tickets --- .../tickets/002-centralized-error-logging.md | 31 ------------------- .../tickets/004-branded-discord-embeds.md | 28 ----------------- 2 files changed, 59 deletions(-) delete mode 100644 .agent/work/tickets/002-centralized-error-logging.md delete mode 100644 .agent/work/tickets/004-branded-discord-embeds.md diff --git a/.agent/work/tickets/002-centralized-error-logging.md b/.agent/work/tickets/002-centralized-error-logging.md deleted file mode 100644 index 607c759..0000000 --- a/.agent/work/tickets/002-centralized-error-logging.md +++ /dev/null @@ -1,31 +0,0 @@ -### Context & Goal - -The bot currently relies on `console.error` which is hard to track and lacks context. A centralized error logging service will allow for better debugging, persistent error logs, and future integration with services like Sentry or Discord webhooks for alerts. - -### Dependencies - -- None - -### Affected Files - -- `shared/lib/logger.ts`: New file for the unified logger service. -- `bot/lib/handlers/CommandHandler.ts`: Update to use the new logger for command errors. -- `web/src/server.ts`: Update to use the new logger for API and WebSocket errors. - -### Technical Constraints & Strategy - -- Implementation: Create a `Logger` class/object in `shared/lib`. -- Support log levels: `info`, `warn`, `error`, `debug`. -- Errors should capture: timestamp, source (bot/web), error message, and stack trace if available. -- For now, logging to console and a local log file (e.g., `logs/error.log`) is sufficient. - -### Definition of Done (Binary) - -- [ ] `Logger` service implemented in `shared/lib/logger.ts`. -- [ ] Command errors are logged via the new service. -- [ ] Web server errors are logged via the new service. -- [ ] Log output is formatted consistently. - -### New Test Files - -- `shared/lib/logger.test.ts`: Verify logger output and file writing. diff --git a/.agent/work/tickets/004-branded-discord-embeds.md b/.agent/work/tickets/004-branded-discord-embeds.md deleted file mode 100644 index 951163c..0000000 --- a/.agent/work/tickets/004-branded-discord-embeds.md +++ /dev/null @@ -1,28 +0,0 @@ -### Context & Goal - -Enhance the user experience by standardizing the look and feel of Discord embeds. Adding consistent branding like a custom footer (with version info) and using the bot's accent color will make the bot feel more professional. - -### Dependencies - -- None - -### Affected Files - -- `bot/lib/embeds.ts`: Update standard embed creators. -- `shared/lib/constants.ts`: Add branding-related constants (colors, footer text). - -### Technical Constraints & Strategy - -- Implementation: Update `createBaseEmbed` and other helpers to automatically include footers and standard colors. -- Use info from `package.json` for versioning in the footer. -- Ensure the changes don't break existing layouts where custom colors might be needed. - -### Definition of Done (Binary) - -- [x] All standard embeds now include a consistent footer. -- [x] Embeds use a predefined brand color by default. -- [x] Version number is automatically pulled for the footer. - -### New Test Files - -- None. From f8436e9755be78acf303e646e955220036c05072 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 11:13:37 +0100 Subject: [PATCH 10/32] chore: (agent) remove tickets and skills --- .agent/skills/create-ticket/SKILL.md | 51 ------------------ .agent/skills/review/SKILL.md | 54 ------------------- .../001-refactor-command-validation.md | 30 ----------- .../work/tickets/003-modularize-web-server.md | 32 ----------- .../work/tickets/005-refactor-exam-logic.md | 29 ---------- 5 files changed, 196 deletions(-) delete mode 100644 .agent/skills/create-ticket/SKILL.md delete mode 100644 .agent/skills/review/SKILL.md delete mode 100644 .agent/work/tickets/001-refactor-command-validation.md delete mode 100644 .agent/work/tickets/003-modularize-web-server.md delete mode 100644 .agent/work/tickets/005-refactor-exam-logic.md diff --git a/.agent/skills/create-ticket/SKILL.md b/.agent/skills/create-ticket/SKILL.md deleted file mode 100644 index f854aea..0000000 --- a/.agent/skills/create-ticket/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: create-ticket -description: Create a ticket for a task that needs to be worked on. ---- - -# Skill: create-ticket - -## Purpose - -Decompose high-level objectives into "Atomic Tickets" to maximize development velocity and minimize cognitive overhead. - -## Execution Rules - -1. **Directory Check:** Scan `.agent/work/tickets/`. Determine sequence number `NNN`. -2. **Naming:** `.agent/work/tickets/NNN-brief-description.md`. -3. **The Atomic/Velocity Test:** - Max 3 files modified. - - Max 80 lines of logic. - - Must be verifiable via a single command or test suite. - - If it exceeds these, **split it.** -4. **Context Injection:** Include relevant code snippets or interface definitions directly in the ticket to prevent "context hunting." -5. **No Breaking Changes:** If a ticket changes a shared interface, it must include the refactor for consumers or be split into a "Transition" ticket. - -## Ticket Template - -### Context & Goal - -[Why this matters + the specific problem it solves.] - -### Dependencies - -- [e.g., Ticket #NNN] - -### Affected Files - -- `path/to/file_A.ext`: [Specific change description] -- `path/to/file_B.ext`: [Specific change description] - -### Technical Constraints & Strategy - -- [e.g., Implementation: Use the existing X wrapper instead of a new fetch call.] -- [e.g., Constraint: Maintain backward compatibility with Y.] - -### Definition of Done (Binary) - -- [ ] Criterion 1 (e.g., `npm test` passes for `X.test.ts`) -- [ ] Criterion 2 (e.g., UI component renders without hydration errors) -- [ ] Criterion 3 (e.g., API response matches the schema in `types.ts`) - -### New Test Files - -- `path/to/test_A.ext`: [What is being tested] diff --git a/.agent/skills/review/SKILL.md b/.agent/skills/review/SKILL.md deleted file mode 100644 index 8481d15..0000000 --- a/.agent/skills/review/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: code-review -description: A "Default-to-Fail" audit of codebase changes. Observation only; no file modifications. ---- - -# Skill: code-review - -## Purpose - -Protect the codebase from "feature creep," technical debt, and weak validation. This skill assumes the latest changes are flawed until they pass a rigorous audit. - -## Execution Rules - -1. **Read-Only Protocol:** This is a diagnostic skill. **Under no circumstances should any files be modified.** Provide feedback only. -2. **Default-to-Fail:** Assume the code is broken or insufficient. The burden of proof lies on the code and its tests. -3. **The Atomic Veto:** - Check the diff. If it exceeds 3 files or 80 lines of logic, **Veto immediately.** - - Reason: "Change exceeds atomic threshold; high risk of cognitive load." -4. **Strictness Audit (Tests):** - - **Veto** if assertions are fuzzy (e.g., `toBeTruthy()`). - - **Veto** if there is no "Red Path" (failure case) test. - - **Veto** if the test is "loose" (e.g., doesn't check specific property values). -5. **Direct Feedback:** No sycophancy. Use "Blockers" for issues and "Verdict: APPROVE" only when the code is bulletproof. - -## Review Template - -### Verdict: [FAIL / APPROVE] - -**Primary Blocker:** [One sentence identifying the biggest reason for rejection.] - ---- - -### 1. Atomic Constraint Check - -- **Files Changed:** [Count] / 3 -- **Logic Lines:** [Count] / 80 -- **Status:** [PASS / FAIL (Veto if FAIL)] - -### 2. Test Strictness Audit - -- **Assertion Quality:** [List specific lines with fuzzy matchers. Demand strict equality.] -- **Failure Coverage:** [Does a test exist for the 'Error/Empty' state? If no, FAIL.] -- **Logic Sync:** [Does the test actually exercise the logic added, or just side effects?] - -### 3. Logic & Resilience - -- **Unchecked States:** [Identify unhandled nulls, undefineds, or missing error catches.] -- **Efficiency:** [Is there a faster path or a redundant operation?] - -### 4. Direct Actionables - -_Note: The reviewer does not apply these. The user/agent must create a ticket or apply fixes manually._ - -1. [Specific fix for Blocker 1] -2. [Specific fix for Blocker 2] diff --git a/.agent/work/tickets/001-refactor-command-validation.md b/.agent/work/tickets/001-refactor-command-validation.md deleted file mode 100644 index 9c0c946..0000000 --- a/.agent/work/tickets/001-refactor-command-validation.md +++ /dev/null @@ -1,30 +0,0 @@ -### Context & Goal - -Currently, every command manually performs checks like user existence or maintenance mode, or these are hardcoded into the `CommandHandler`. Standardizing these requirements in the command definition itself makes the code cleaner and more declarative. - -### Dependencies - -- None - -### Affected Files - -- `shared/lib/types.ts`: Update `Command` interface to include a optional `requirements` object. -- `bot/lib/handlers/CommandHandler.ts`: Update to read and enforce these requirements. -- `bot/commands/economy/balance.ts`: Refactor to use the new requirements (example). - -### Technical Constraints & Strategy - -- Implementation: Use a standardized `requirements` object in the `Command` interface. -- Requirements could include: `userExists: boolean`, `permissions: string[]`, `devOnly: boolean`. -- Ensure `CommandHandler` provides clear error messages to the user when a requirement fails. - -### Definition of Done (Binary) - -- [ ] `Command` interface updated in `types.ts`. -- [ ] `CommandHandler.ts` enforces requirements before executing command. -- [ ] At least one command (e.g., `balance`) is refactored to use the new system. -- [ ] Clear error embeds are shown to the user when requirements aren't met. - -### New Test Files - -- None (Verification via manual testing of command execution). diff --git a/.agent/work/tickets/003-modularize-web-server.md b/.agent/work/tickets/003-modularize-web-server.md deleted file mode 100644 index 45b7db2..0000000 --- a/.agent/work/tickets/003-modularize-web-server.md +++ /dev/null @@ -1,32 +0,0 @@ -### Context & Goal - -The current `web/src/server.ts` is a monolithic file with a very long `fetch` handler. This makes it difficult to read and maintain. Modularizing the logic into separate route handlers and middleware-like functions will improve code quality and scalability. - -### Dependencies - -- None - -### Affected Files - -- `web/src/server.ts`: Refactor to use modular handlers. -- `web/src/routes/api.ts`: New file for API route definitions. -- `web/src/routes/static.ts`: New file for static file serving logic. -- `web/src/routes/websocket.ts`: New file for WebSocket event handling. - -### Technical Constraints & Strategy - -- Implementation: Move different responsibilities (API, Static, WS) into separate files. -- The main `serve` configuration should just call these modules. -- Ensure the SPA fallback logic remains intact. - -### Definition of Done (Binary) - -- [ ] `web/src/server.ts` length reduced by at least 50%. -- [ ] API routes moved to dedicated module. -- [ ] Static file serving moved to dedicated module. -- [ ] WebSocket logic moved to dedicated module. -- [ ] Dashboard still loads and functions correctly. - -### New Test Files - -- None (Verification via manual testing of the dashboard). diff --git a/.agent/work/tickets/005-refactor-exam-logic.md b/.agent/work/tickets/005-refactor-exam-logic.md deleted file mode 100644 index 3c65355..0000000 --- a/.agent/work/tickets/005-refactor-exam-logic.md +++ /dev/null @@ -1,29 +0,0 @@ -### Context & Goal - -The `exam` command currently contains a lot of business logic, including reward calculations, timer management, and complex database transactions. Moving this logic to a dedicated `ExamService` will improve testability, maintainability, and keep the command file focused on user interaction. - -### Dependencies - -- None - -### Affected Files - -- `shared/modules/economy/exam.service.ts`: New file for the exam logic. -- `bot/commands/economy/exam.ts`: Refactor to use the new service. - -### Technical Constraints & Strategy - -- Implementation: Create an `ExamService` that handles `getExamStatus`, `takeExam`, and `registerForExam`. -- The command should only handle user input and formatting the response embeds based on the service's result. -- Ensure the Drizzle transactions are correctly handled within the service. - -### Definition of Done (Binary) - -- [ ] `ExamService` implemented with methods for all exam-related operations. -- [ ] `bot/commands/economy/exam.ts` refactored to use the service. -- [ ] Logic is covered by unit tests in a new test file. -- [ ] Manual verification shows the exam command still works as expected. - -### New Test Files - -- `shared/modules/economy/exam.service.test.ts`: Unit tests for reward calculations and state transitions. From 52f8ab11f08815783a1c59aa3715d0a8216e7d99 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 15:04:50 +0100 Subject: [PATCH 11/32] feat: Implement quest event handling and integrate it into leveling, economy, and inventory services. --- shared/modules/economy/economy.service.ts | 4 ++ shared/modules/inventory/inventory.service.ts | 14 +++++ shared/modules/leveling/leveling.service.ts | 4 ++ shared/modules/quest/quest.service.test.ts | 56 +++++++++++++++++++ shared/modules/quest/quest.service.ts | 31 ++++++++++ 5 files changed, 109 insertions(+) diff --git a/shared/modules/economy/economy.service.ts b/shared/modules/economy/economy.service.ts index 6d026e4..f12eab0 100644 --- a/shared/modules/economy/economy.service.ts +++ b/shared/modules/economy/economy.service.ts @@ -196,6 +196,10 @@ export const economyService = { description: description, }); + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(id, type, 1, txFn); + return user; }, tx); }, diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 7f702d4..7f7628b 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -37,6 +37,11 @@ export const inventoryService = { eq(inventory.itemId, itemId) )) .returning(); + + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, 'ITEM_COLLECT', Number(quantity), txFn); + return entry; } else { // Check Slot Limit @@ -60,6 +65,11 @@ export const inventoryService = { quantity: quantity, }) .returning(); + + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, 'ITEM_COLLECT', Number(quantity), txFn); + return entry; } }, tx); @@ -179,6 +189,10 @@ export const inventoryService = { await inventoryService.removeItem(userId, itemId, 1n, txFn); } + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(userId, 'ITEM_USE', 1, txFn); + return { success: true, results, usageData, item }; }, tx); }, diff --git a/shared/modules/leveling/leveling.service.ts b/shared/modules/leveling/leveling.service.ts index f6af7e5..a009c51 100644 --- a/shared/modules/leveling/leveling.service.ts +++ b/shared/modules/leveling/leveling.service.ts @@ -68,6 +68,10 @@ export const levelingService = { .where(eq(users.id, BigInt(id))) .returning(); + // Trigger Quest Event + const { questService } = await import("@shared/modules/quest/quest.service"); + await questService.handleEvent(id, 'XP_GAIN', Number(amount), txFn); + return { user: updatedUser, levelUp, currentLevel: newLevel }; }, tx); }, diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 1cd1b73..6ab616d 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -148,4 +148,60 @@ describe("questService", () => { expect(result).toEqual(mockData as any); }); }); + + describe("handleEvent", () => { + it("should progress a quest", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 0, + completedAt: null, + quest: { triggerEvent: "TEST_EVENT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]); + + await questService.handleEvent("1", "TEST_EVENT", 1); + + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ progress: 1 }); + }); + + it("should complete a quest when target reached", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 4, + completedAt: null, + quest: { + triggerEvent: "TEST_EVENT", + requirements: { target: 5 }, + rewards: { balance: 100 } + } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest + + await questService.handleEvent("1", "TEST_EVENT", 1); + + // Verify completeQuest was called (it will update completedAt) + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) }); + }); + + it("should ignore irrelevant events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 101, + progress: 0, + completedAt: null, + quest: { triggerEvent: "DIFFERENT_EVENT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "TEST_EVENT", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index db1199b..4705496 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -34,6 +34,37 @@ export const questService = { }, tx); }, + handleEvent: async (userId: string, eventName: string, weight: number = 1, tx?: Transaction) => { + return await withTransaction(async (txFn) => { + // 1. Fetch active user quests for this event + const activeUserQuests = await txFn.query.userQuests.findMany({ + where: and( + eq(userQuests.userId, BigInt(userId)), + ), + with: { + quest: true + } + }); + + const relevant = activeUserQuests.filter(uq => + uq.quest.triggerEvent === eventName && !uq.completedAt + ); + + for (const uq of relevant) { + const requirements = uq.quest.requirements as { target?: number }; + const target = requirements?.target || 1; + + const newProgress = (uq.progress || 0) + weight; + + if (newProgress >= target) { + await questService.completeQuest(userId, uq.questId, txFn); + } else { + await questService.updateProgress(userId, uq.questId, newProgress, txFn); + } + } + }, tx); + }, + completeQuest: async (userId: string, questId: number, tx?: Transaction) => { return await withTransaction(async (txFn) => { const userQuest = await txFn.query.userQuests.findFirst({ From 7d541825d8892334e1705209fffc8428742bbf06 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 15:09:37 +0100 Subject: [PATCH 12/32] feat: Update quest event triggers to include item IDs for granular tracking. --- shared/modules/inventory/inventory.service.ts | 6 +++--- shared/modules/quest/quest.service.test.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 7f7628b..167b1e7 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -40,7 +40,7 @@ export const inventoryService = { // Trigger Quest Event const { questService } = await import("@shared/modules/quest/quest.service"); - await questService.handleEvent(userId, 'ITEM_COLLECT', Number(quantity), txFn); + await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn); return entry; } else { @@ -68,7 +68,7 @@ export const inventoryService = { // Trigger Quest Event const { questService } = await import("@shared/modules/quest/quest.service"); - await questService.handleEvent(userId, 'ITEM_COLLECT', Number(quantity), txFn); + await questService.handleEvent(userId, `ITEM_COLLECT:${itemId}`, Number(quantity), txFn); return entry; } @@ -191,7 +191,7 @@ export const inventoryService = { // Trigger Quest Event const { questService } = await import("@shared/modules/quest/quest.service"); - await questService.handleEvent(userId, 'ITEM_USE', 1, txFn); + await questService.handleEvent(userId, `ITEM_USE:${itemId}`, 1, txFn); return { success: true, results, usageData, item }; }, tx); diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 6ab616d..9f1917c 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -150,31 +150,31 @@ describe("questService", () => { }); describe("handleEvent", () => { - it("should progress a quest", async () => { + it("should progress a quest with sub-events", async () => { const mockUserQuest = { userId: 1n, questId: 101, progress: 0, completedAt: null, - quest: { triggerEvent: "TEST_EVENT", requirements: { target: 5 } } + quest: { triggerEvent: "ITEM_USE:101", requirements: { target: 5 } } }; mockFindMany.mockResolvedValue([mockUserQuest]); mockReturning.mockResolvedValue([{ userId: 1n, questId: 101, progress: 1 }]); - await questService.handleEvent("1", "TEST_EVENT", 1); + await questService.handleEvent("1", "ITEM_USE:101", 1); expect(mockUpdate).toHaveBeenCalled(); expect(mockSet).toHaveBeenCalledWith({ progress: 1 }); }); - it("should complete a quest when target reached", async () => { + it("should complete a quest when target reached using sub-events", async () => { const mockUserQuest = { userId: 1n, questId: 101, progress: 4, completedAt: null, quest: { - triggerEvent: "TEST_EVENT", + triggerEvent: "ITEM_COLLECT:505", requirements: { target: 5 }, rewards: { balance: 100 } } @@ -182,7 +182,7 @@ describe("questService", () => { mockFindMany.mockResolvedValue([mockUserQuest]); mockFindFirst.mockResolvedValue(mockUserQuest); // For completeQuest - await questService.handleEvent("1", "TEST_EVENT", 1); + await questService.handleEvent("1", "ITEM_COLLECT:505", 1); // Verify completeQuest was called (it will update completedAt) expect(mockUpdate).toHaveBeenCalled(); From eb108695d3a16b1757e56a9985967dbb23b46d8b Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 15:22:20 +0100 Subject: [PATCH 13/32] feat: Implement flexible quest event matching to allow generic triggers to match specific event instances. --- shared/modules/quest/quest.service.test.ts | 62 ++++++++++++++++++++++ shared/modules/quest/quest.service.ts | 9 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 9f1917c..220523d 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -189,6 +189,68 @@ describe("questService", () => { expect(mockSet).toHaveBeenCalledWith({ completedAt: expect.any(Date) }); }); + it("should progress a quest with generic events", async () => { + const mockUserQuest = { + userId: 1n, + questId: 102, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + mockReturning.mockResolvedValue([{ userId: 1n, questId: 102, progress: 1 }]); + + await questService.handleEvent("1", "ITEM_COLLECT:505", 1); + + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith({ progress: 1 }); + }); + + it("should ignore events that are not prefix matches", async () => { + const mockUserQuest = { + userId: 1n, + questId: 103, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT_UNRELATED", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should not progress a specific quest with a different specific event", async () => { + const mockUserQuest = { + userId: 1n, + questId: 104, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT:202", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("should not progress a specific quest with a generic event", async () => { + const mockUserQuest = { + userId: 1n, + questId: 105, + progress: 0, + completedAt: null, + quest: { triggerEvent: "ITEM_COLLECT:101", requirements: { target: 5 } } + }; + mockFindMany.mockResolvedValue([mockUserQuest]); + + await questService.handleEvent("1", "ITEM_COLLECT", 1); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it("should ignore irrelevant events", async () => { const mockUserQuest = { userId: 1n, diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 4705496..1bf8ed6 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -46,9 +46,12 @@ export const questService = { } }); - const relevant = activeUserQuests.filter(uq => - uq.quest.triggerEvent === eventName && !uq.completedAt - ); + const relevant = activeUserQuests.filter(uq => { + const trigger = uq.quest.triggerEvent; + // Exact match or prefix match (e.g. ITEM_COLLECT matches ITEM_COLLECT:101) + const isMatch = eventName === trigger || eventName.startsWith(trigger + ":"); + return isMatch && !uq.completedAt; + }); for (const uq of relevant) { const requirements = uq.quest.requirements as { target?: number }; From 9e5c6b5ac3bddf5860a8e7c754db8787705d413c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 15:30:01 +0100 Subject: [PATCH 14/32] feat: Implement interactive quest command allowing users to view active/available quests and accept new ones. --- bot/commands/quest/quests.ts | 73 +++++++++++--- bot/modules/quest/quest.view.ts | 112 ++++++++++++++++++++- shared/modules/quest/quest.service.test.ts | 26 +++++ shared/modules/quest/quest.service.ts | 15 +++ 4 files changed, 212 insertions(+), 14 deletions(-) diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index fab6621..32262e5 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -1,25 +1,74 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, MessageFlags } from "discord.js"; +import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js"; import { questService } from "@shared/modules/quest/quest.service"; -import { createWarningEmbed } from "@lib/embeds"; -import { getQuestListEmbed } from "@/modules/quest/quest.view"; +import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds"; +import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view"; export const quests = createCommand({ data: new SlashCommandBuilder() .setName("quests") - .setDescription("View your active quests"), + .setDescription("View your active and available quests"), execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const userQuests = await questService.getUserQuests(interaction.user.id); + const userId = interaction.user.id; - if (!userQuests || userQuests.length === 0) { - await interaction.editReply({ embeds: [createWarningEmbed("You have no active quests.", "Quest Log")] }); - return; - } + const updateView = async (viewType: 'active' | 'available') => { + const userQuests = await questService.getUserQuests(userId); + const availableQuests = await questService.getAvailableQuests(userId); - const embed = getQuestListEmbed(userQuests); + const embed = viewType === 'active' + ? getQuestListEmbed(userQuests) + : getAvailableQuestsEmbed(availableQuests); + + const components = getQuestActionRows(viewType, availableQuests); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ + embeds: [embed], + components: components + }); + }; + + // Initial view + await updateView('active'); + + const collector = response.createMessageComponentCollector({ + time: 60000, + componentType: undefined // Allow both buttons and select menu + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) return; + + try { + if (i.customId === "quest_view_active") { + await i.deferUpdate(); + await updateView('active'); + } else if (i.customId === "quest_view_available") { + await i.deferUpdate(); + await updateView('available'); + } else if (i.customId === "quest_accept_select") { + const questId = parseInt((i as any).values[0]); + await questService.assignQuest(userId, questId); + + await i.reply({ + embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], + flags: MessageFlags.Ephemeral + }); + + await updateView('active'); + } + } catch (error) { + console.error("Quest interaction error:", error); + await i.followUp({ + content: "Something went wrong while processing your quest interaction.", + flags: MessageFlags.Ephemeral + }); + } + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(() => {}); + }); } }); diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index e090c8e..6343dc4 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder } from "discord.js"; +import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; /** * Quest entry with quest details and progress @@ -7,12 +7,26 @@ interface QuestEntry { progress: number | null; completedAt: Date | null; quest: { + id: number; name: string; description: string | null; + triggerEvent: string; + requirements: any; rewards: any; }; } +/** + * Available quest interface + */ +interface AvailableQuest { + id: number; + name: string; + description: string | null; + rewards: any; + requirements: any; +} + /** * Formats quest rewards object into a human-readable string */ @@ -30,25 +44,119 @@ function getQuestStatus(completedAt: Date | null): string { return completedAt ? "βœ… Completed" : "πŸ“ In Progress"; } +/** + * Renders a simple progress bar + */ +function renderProgressBar(current: number, total: number, size: number = 10): string { + const percentage = Math.min(current / total, 1); + const progress = Math.round(size * percentage); + const empty = size - progress; + + const progressText = "β–°".repeat(progress); + const emptyText = "β–±".repeat(empty); + + return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`; +} + /** * Creates an embed displaying a user's quest log */ export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder { const embed = new EmbedBuilder() .setTitle("πŸ“œ Quest Log") + .setDescription("Your active and completed quests.") .setColor(0x3498db); // Blue + if (userQuests.length === 0) { + embed.setDescription("You have no active quests. Check available quests!"); + } + userQuests.forEach(entry => { const status = getQuestStatus(entry.completedAt); const rewards = entry.quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); + + const requirements = entry.quest.requirements as { target?: number }; + const target = requirements?.target || 1; + const progress = entry.progress || 0; + + const progressBar = entry.completedAt ? "βœ… Fully completed" : renderProgressBar(progress, target); embed.addFields({ name: `${entry.quest.name} (${status})`, - value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${entry.progress}%`, + value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`, inline: false }); }); return embed; } + +/** + * Creates an embed for available quests + */ +export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle("πŸ—ΊοΈ Available Quests") + .setDescription("Quests you can accept right now.") + .setColor(0x2ecc71); // Green + + if (availableQuests.length === 0) { + embed.setDescription("There are no new quests available for you at the moment."); + } + + availableQuests.forEach(quest => { + const rewards = quest.rewards as { xp?: number, balance?: number }; + const rewardsText = formatQuestRewards(rewards); + + const requirements = quest.requirements as { target?: number }; + const target = requirements?.target || 1; + + embed.addFields({ + name: quest.name, + value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`, + inline: false + }); + }); + + return embed; +} + +/** + * Returns action rows for the quest view + */ +export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + + const navRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("quest_view_active") + .setLabel("Active Quests") + .setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'active'), + new ButtonBuilder() + .setCustomId("quest_view_available") + .setLabel("Available Quests") + .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setDisabled(viewType === 'available') + ); + rows.push(navRow); + + if (viewType === 'available' && availableQuests.length > 0) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId("quest_accept_select") + .setPlaceholder("Select a quest to accept") + .addOptions( + availableQuests.slice(0, 25).map(q => + new StringSelectMenuOptionBuilder() + .setLabel(q.name) + .setDescription(q.description?.substring(0, 100) || "") + .setValue(q.id.toString()) + ) + ); + + rows.push(new ActionRowBuilder().addComponents(selectMenu)); + } + + return rows; +} diff --git a/shared/modules/quest/quest.service.test.ts b/shared/modules/quest/quest.service.test.ts index 220523d..96cadc6 100644 --- a/shared/modules/quest/quest.service.test.ts +++ b/shared/modules/quest/quest.service.test.ts @@ -33,6 +33,7 @@ mock.module("@shared/db/DrizzleClient", () => { const createMockTx = () => ({ query: { userQuests: { findFirst: mockFindFirst, findMany: mockFindMany }, + quests: { findMany: mockFindMany }, }, insert: mockInsert, update: mockUpdate, @@ -149,6 +150,31 @@ describe("questService", () => { }); }); + describe("getAvailableQuests", () => { + it("should return quests not yet accepted by user", async () => { + // First call to findMany (userQuests) returns accepted quest IDs + // Second call to findMany (quests) returns available quests + mockFindMany + .mockResolvedValueOnce([{ questId: 1 }]) // userQuests + .mockResolvedValueOnce([{ id: 2, name: "New Quest" }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 2, name: "New Quest" }] as any); + expect(mockFindMany).toHaveBeenCalledTimes(2); + }); + + it("should return all quests if user has no assigned quests", async () => { + mockFindMany + .mockResolvedValueOnce([]) // userQuests + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); // quests + + const result = await questService.getAvailableQuests("1"); + + expect(result).toEqual([{ id: 1 }, { id: 2 }] as any); + }); + }); + describe("handleEvent", () => { it("should progress a quest with sub-events", async () => { const mockUserQuest = { diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 1bf8ed6..4cc187f 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -118,5 +118,20 @@ export const questService = { quest: true, } }); + }, + + getAvailableQuests: async (userId: string) => { + const userQuestIds = (await DrizzleClient.query.userQuests.findMany({ + where: eq(userQuests.userId, BigInt(userId)), + columns: { + questId: true + } + })).map(uq => uq.questId); + + return await DrizzleClient.query.quests.findMany({ + where: (quests, { notInArray }) => userQuestIds.length > 0 + ? notInArray(quests.id, userQuestIds) + : undefined + }); } }; From 2f73f38877f95b995ff1c877ee17cfa59cac9f0d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 17:21:49 +0100 Subject: [PATCH 15/32] feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components. --- bot/commands/quest/quests.ts | 53 +++--- bot/lib/BotClient.ts | 25 ++- bot/modules/quest/quest.view.ts | 192 +++++++++++++-------- shared/lib/events.ts | 3 + shared/modules/quest/quest.service.ts | 33 +++- web/src/App.tsx | 2 + web/src/components/quest-form.tsx | 230 ++++++++++++++++++++++++++ web/src/pages/AdminQuests.tsx | 59 +++++++ web/src/pages/Dashboard.tsx | 3 + web/src/pages/DesignSystem.tsx | 15 ++ web/src/pages/Home.tsx | 3 + web/src/server.ts | 28 ++++ 12 files changed, 552 insertions(+), 94 deletions(-) create mode 100644 web/src/components/quest-form.tsx create mode 100644 web/src/pages/AdminQuests.tsx diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index 32262e5..a3f053a 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -1,8 +1,12 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js"; +import { SlashCommandBuilder, MessageFlags } from "discord.js"; import { questService } from "@shared/modules/quest/quest.service"; -import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds"; -import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view"; +import { createSuccessEmbed } from "@lib/embeds"; +import { + getQuestListComponents, + getAvailableQuestsComponents, + getQuestActionRows +} from "@/modules/quest/quest.view"; export const quests = createCommand({ data: new SlashCommandBuilder() @@ -17,15 +21,18 @@ export const quests = createCommand({ const userQuests = await questService.getUserQuests(userId); const availableQuests = await questService.getAvailableQuests(userId); - const embed = viewType === 'active' - ? getQuestListEmbed(userQuests) - : getAvailableQuestsEmbed(availableQuests); - - const components = getQuestActionRows(viewType, availableQuests); + const containers = viewType === 'active' + ? getQuestListComponents(userQuests) + : getAvailableQuestsComponents(availableQuests); + + const actionRows = getQuestActionRows(viewType); await interaction.editReply({ - embeds: [embed], - components: components + content: null, + embeds: null as any, + components: [...containers, ...actionRows] as any, + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] } }); }; @@ -33,8 +40,8 @@ export const quests = createCommand({ await updateView('active'); const collector = response.createMessageComponentCollector({ - time: 60000, - componentType: undefined // Allow both buttons and select menu + time: 120000, // 2 minutes + componentType: undefined // Allow buttons }); collector.on('collect', async (i) => { @@ -47,22 +54,24 @@ export const quests = createCommand({ } else if (i.customId === "quest_view_available") { await i.deferUpdate(); await updateView('available'); - } else if (i.customId === "quest_accept_select") { - const questId = parseInt((i as any).values[0]); + } else if (i.customId.startsWith("quest_accept:")) { + const questIdStr = i.customId.split(":")[1]; + if (!questIdStr) return; + const questId = parseInt(questIdStr); await questService.assignQuest(userId, questId); - - await i.reply({ - embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], - flags: MessageFlags.Ephemeral + + await i.reply({ + embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], + flags: MessageFlags.Ephemeral }); - + await updateView('active'); } } catch (error) { console.error("Quest interaction error:", error); - await i.followUp({ - content: "Something went wrong while processing your quest interaction.", - flags: MessageFlags.Ephemeral + await i.followUp({ + content: "Something went wrong while processing your quest interaction.", + flags: MessageFlags.Ephemeral }); } }); diff --git a/bot/lib/BotClient.ts b/bot/lib/BotClient.ts index d347f08..de0fdb6 100644 --- a/bot/lib/BotClient.ts +++ b/bot/lib/BotClient.ts @@ -1,4 +1,4 @@ -import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; +import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js"; import { join } from "node:path"; import type { Command } from "@shared/lib/types"; import { env } from "@shared/lib/env"; @@ -74,6 +74,27 @@ export class Client extends DiscordClient { console.log(`πŸ› οΈ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`); this.maintenanceMode = enabled; }); + + systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => { + const { userId, quest, rewards } = data; + try { + const user = await this.users.fetch(userId); + if (!user) return; + + const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view"); + const components = getQuestCompletionComponents(quest, rewards); + + // Try to send to the user's DM + await user.send({ + components: components as any, + flags: [MessageFlags.IsComponentsV2] + }).catch(async () => { + console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`); + }); + } catch (error) { + console.error("Failed to send quest completion notification:", error); + } + }); } async loadCommands(reload: boolean = false) { @@ -176,4 +197,4 @@ export class Client extends DiscordClient { } } -export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] }); \ No newline at end of file +export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] }); \ No newline at end of file diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index 6343dc4..2e18283 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -1,4 +1,13 @@ -import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + MessageFlags +} from "discord.js"; /** * Quest entry with quest details and progress @@ -27,6 +36,13 @@ interface AvailableQuest { requirements: any; } +// Color palette for containers +const COLORS = { + ACTIVE: 0x3498db, // Blue - in progress + AVAILABLE: 0x2ecc71, // Green - available + COMPLETED: 0xf1c40f // Gold - completed +}; + /** * Formats quest rewards object into a human-readable string */ @@ -34,14 +50,7 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string const rewardStr: string[] = []; if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`); if (rewards?.balance) rewardStr.push(`${rewards.balance} πŸͺ™`); - return rewardStr.join(", "); -} - -/** - * Returns the quest status display string - */ -function getQuestStatus(completedAt: Date | null): string { - return completedAt ? "βœ… Completed" : "πŸ“ In Progress"; + return rewardStr.join(" β€’ ") || "None"; } /** @@ -55,108 +64,155 @@ function renderProgressBar(current: number, total: number, size: number = 10): s const progressText = "β–°".repeat(progress); const emptyText = "β–±".repeat(empty); - return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`; + return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`; } /** - * Creates an embed displaying a user's quest log + * Creates Components v2 containers for the quest list (active quests only) */ -export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle("πŸ“œ Quest Log") - .setDescription("Your active and completed quests.") - .setColor(0x3498db); // Blue +export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] { + // Filter to only show in-progress quests (not completed) + const activeQuests = userQuests.filter(entry => entry.completedAt === null); - if (userQuests.length === 0) { - embed.setDescription("You have no active quests. Check available quests!"); + const container = new ContainerBuilder() + .setAccentColor(COLORS.ACTIVE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸ“œ Quest Log"), + new TextDisplayBuilder().setContent("-# Your active quests") + ); + + if (activeQuests.length === 0) { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*") + ); + return [container]; } - userQuests.forEach(entry => { - const status = getQuestStatus(entry.completedAt); + activeQuests.forEach((entry) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + const rewards = entry.quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); - + const requirements = entry.quest.requirements as { target?: number }; const target = requirements?.target || 1; const progress = entry.progress || 0; + const progressBar = renderProgressBar(progress, target); - const progressBar = entry.completedAt ? "βœ… Fully completed" : renderProgressBar(progress, target); - - embed.addFields({ - name: `${entry.quest.name} (${status})`, - value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`, - inline: false - }); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${entry.quest.name}**`), + new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`πŸ“Š ${progressBar} \`${progress}/${target}\` β€’ 🎁 ${rewardsText}`) + ); }); - return embed; + return [container]; } /** - * Creates an embed for available quests + * Creates Components v2 containers for available quests with inline accept buttons */ -export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle("πŸ—ΊοΈ Available Quests") - .setDescription("Quests you can accept right now.") - .setColor(0x2ecc71); // Green +export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] { + const container = new ContainerBuilder() + .setAccentColor(COLORS.AVAILABLE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸ—ΊοΈ Available Quests"), + new TextDisplayBuilder().setContent("-# Quests you can accept") + ); if (availableQuests.length === 0) { - embed.setDescription("There are no new quests available for you at the moment."); + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*No new quests available at the moment.*") + ); + return [container]; } - availableQuests.forEach(quest => { + // Limit to 10 quests (5 action rows max with 2 added for navigation) + const questsToShow = availableQuests.slice(0, 10); + + questsToShow.forEach((quest) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + const rewards = quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); - + const requirements = quest.requirements as { target?: number }; const target = requirements?.target || 1; - embed.addFields({ - name: quest.name, - value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`, - inline: false - }); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${quest.name}**`), + new TextDisplayBuilder().setContent(quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` β€’ 🎁 ${rewardsText}`) + ); + + // Add accept button inline within the container + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`quest_accept:${quest.id}`) + .setLabel("Accept Quest") + .setStyle(ButtonStyle.Success) + .setEmoji("βœ…") + ) + ); }); - return embed; + return [container]; } /** - * Returns action rows for the quest view + * Returns action rows for navigation only */ -export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder[] { - const rows: ActionRowBuilder[] = []; - +export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder[] { + // Navigation row const navRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("quest_view_active") - .setLabel("Active Quests") + .setLabel("πŸ“œ Active") .setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(viewType === 'active'), new ButtonBuilder() .setCustomId("quest_view_available") - .setLabel("Available Quests") + .setLabel("πŸ—ΊοΈ Available") .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(viewType === 'available') ); - rows.push(navRow); - if (viewType === 'available' && availableQuests.length > 0) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId("quest_accept_select") - .setPlaceholder("Select a quest to accept") - .addOptions( - availableQuests.slice(0, 25).map(q => - new StringSelectMenuOptionBuilder() - .setLabel(q.name) - .setDescription(q.description?.substring(0, 100) || "") - .setValue(q.id.toString()) - ) - ); - - rows.push(new ActionRowBuilder().addComponents(selectMenu)); - } - - return rows; + return [navRow]; +} + +/** + * Creates Components v2 celebratory message for quest completion + */ +export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] { + const rewardsText = formatQuestRewards({ + xp: Number(rewards.xp), + balance: Number(rewards.balance) + }); + + const container = new ContainerBuilder() + .setAccentColor(COLORS.COMPLETED) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸŽ‰ Quest Completed!"), + new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`) + ) + .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`πŸ“ ${quest.description || "No description provided."}`), + new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`) + ); + + return [container]; +} + +/** + * Gets MessageFlags and allowedMentions for Components v2 messages + */ +export function getComponentsV2MessageFlags() { + return { + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] as const } + }; } diff --git a/shared/lib/events.ts b/shared/lib/events.ts index aff8748..258c13a 100644 --- a/shared/lib/events.ts +++ b/shared/lib/events.ts @@ -17,5 +17,8 @@ export const EVENTS = { RELOAD_COMMANDS: "actions:reload_commands", CLEAR_CACHE: "actions:clear_cache", MAINTENANCE_MODE: "actions:maintenance_mode", + }, + QUEST: { + COMPLETED: "quest:completed", } } as const; diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 4cc187f..72ec622 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -1,4 +1,4 @@ -import { userQuests } from "@db/schema"; +import { userQuests, quests } from "@db/schema"; import { eq, and } from "drizzle-orm"; import { UserError } from "@shared/lib/errors"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service"; import { withTransaction } from "@/lib/db"; import type { Transaction } from "@shared/lib/types"; import { TransactionType } from "@shared/lib/constants"; +import { systemEvents, EVENTS } from "@shared/lib/events"; export const questService = { assignQuest: async (userId: string, questId: number, tx?: Transaction) => { @@ -107,6 +108,14 @@ export const questService = { results.xp = xp; } + // Emit completion event for the bot to handle notifications + systemEvents.emit(EVENTS.QUEST.COMPLETED, { + userId, + questId, + quest: userQuest.quest, + rewards: results + }); + return { success: true, rewards: results }; }, tx); }, @@ -120,7 +129,7 @@ export const questService = { }); }, - getAvailableQuests: async (userId: string) => { + async getAvailableQuests(userId: string) { const userQuestIds = (await DrizzleClient.query.userQuests.findMany({ where: eq(userQuests.userId, BigInt(userId)), columns: { @@ -133,5 +142,25 @@ export const questService = { ? notInArray(quests.id, userQuestIds) : undefined }); + }, + + async createQuest(data: { + name: string; + description: string; + triggerEvent: string; + requirements: { target: number }; + rewards: { xp: number; balance: number }; + }, tx?: Transaction) { + return await withTransaction(async (txFn) => { + return await txFn.insert(quests) + .values({ + name: data.name, + description: data.description, + triggerEvent: data.triggerEvent, + requirements: data.requirements, + rewards: data.rewards, + }) + .returning(); + }, tx); } }; diff --git a/web/src/App.tsx b/web/src/App.tsx index f6362d3..3ffa537 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import "./index.css"; import { Dashboard } from "./pages/Dashboard"; import { DesignSystem } from "./pages/DesignSystem"; +import { AdminQuests } from "./pages/AdminQuests"; import { Home } from "./pages/Home"; import { Toaster } from "sonner"; @@ -12,6 +13,7 @@ export function App() { } /> } /> + } /> } /> diff --git a/web/src/components/quest-form.tsx b/web/src/components/quest-form.tsx new file mode 100644 index 0000000..3cfbed1 --- /dev/null +++ b/web/src/components/quest-form.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./ui/card"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./ui/form"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { toast } from "sonner"; +import { ScrollArea } from "./ui/scroll-area"; + +const questSchema = z.object({ + name: z.string().min(3, "Name must be at least 3 characters"), + description: z.string().optional(), + triggerEvent: z.string().min(1, "Trigger event is required"), + target: z.number().min(1, "Target must be at least 1"), + xpReward: z.number().min(0).optional(), + balanceReward: z.number().min(0).optional(), +}); + +type QuestFormValues = z.infer; + +const TRIGGER_EVENTS = [ + { label: "XP Gain", value: "XP_GAIN" }, + { label: "Item Collect", value: "ITEM_COLLECT" }, + { label: "Item Use", value: "ITEM_USE" }, + { label: "Daily Reward", value: "DAILY_REWARD" }, + { label: "Lootbox Currency Reward", value: "LOOTBOX" }, + { label: "Exam Reward", value: "EXAM_REWARD" }, + { label: "Purchase", value: "PURCHASE" }, + { label: "Transfer In", value: "TRANSFER_IN" }, + { label: "Transfer Out", value: "TRANSFER_OUT" }, + { label: "Trade In", value: "TRADE_IN" }, + { label: "Trade Out", value: "TRADE_OUT" }, + { label: "Quest Reward", value: "QUEST_REWARD" }, + { label: "Trivia Entry", value: "TRIVIA_ENTRY" }, + { label: "Trivia Win", value: "TRIVIA_WIN" }, +]; + +export function QuestForm() { + const [isSubmitting, setIsSubmitting] = React.useState(false); + const form = useForm({ + resolver: zodResolver(questSchema), + defaultValues: { + name: "", + description: "", + triggerEvent: "XP_GAIN", + target: 1, + xpReward: 100, + balanceReward: 500, + }, + }); + + const onSubmit = async (data: QuestFormValues) => { + setIsSubmitting(true); + try { + const response = await fetch("/api/quests", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create quest"); + } + + toast.success("Quest created successfully!", { + description: `${data.name} has been added to the database.`, + }); + form.reset(); + } catch (error) { + console.error("Submission error:", error); + toast.error("Failed to create quest", { + description: error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + Create New Quest + + Configure a new quest for the Aurora RPG academy. + + + +
+ +
+ ( + + Quest Name + + + + + + )} + /> + + ( + + Trigger Event + + + + )} + /> +
+ + ( + + Description + +