diff --git a/.agents/skills/cavecrew/README.md b/.agents/skills/cavecrew/README.md new file mode 100644 index 0000000..e722952 --- /dev/null +++ b/.agents/skills/cavecrew/README.md @@ -0,0 +1,41 @@ +# cavecrew + +Decision guide. When to delegate to caveman subagents instead of doing the work inline. + +## What it does + +Tells the main thread when to spawn a caveman-style subagent versus the vanilla equivalent. The win: subagent tool-results inject back into main context verbatim, and caveman output is roughly 1/3 the size of vanilla prose. Across 20 delegations in one session, that is the difference between context exhaustion and finishing the task. + +Three subagents: + +| Subagent | Job | Use when | +|----------|-----|----------| +| `cavecrew-investigator` | Locate code (read-only) | "Where is X defined / what calls Y / list uses of Z" | +| `cavecrew-builder` | Surgical edit, 1-2 files | Scope is obvious, ≤2 files. Refuses 3+ file scope. | +| `cavecrew-reviewer` | Diff/file review | One-line findings with severity emoji | + +Use vanilla `Explore` or `Code Reviewer` when you want prose, architecture commentary, or rationale. Use main thread directly for one-line answers and 3+ file refactors. + +This skill is a decision guide, not a slash command. It activates when the conversation mentions delegation. + +## How to invoke + +Triggers on phrases like "delegate to subagent", "use cavecrew", "spawn investigator", "save context", "compressed agent output". + +## Example chaining + +Locate → fix → verify (most common): + +1. `cavecrew-investigator` returns site list (`path:line — symbol — note`) +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder` +3. `cavecrew-reviewer` audits the resulting diff + +Parallel scout: spawn 2-3 `cavecrew-investigator` calls in one message with different angles (defs, callers, tests). Aggregate in main. + +## See also + +- [`SKILL.md`](./SKILL.md) — full decision matrix and output contracts +- [`agents/cavecrew-investigator.md`](../../agents/cavecrew-investigator.md) +- [`agents/cavecrew-builder.md`](../../agents/cavecrew-builder.md) +- [`agents/cavecrew-reviewer.md`](../../agents/cavecrew-reviewer.md) +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/cavecrew/SKILL.md b/.agents/skills/cavecrew/SKILL.md new file mode 100644 index 0000000..efa413f --- /dev/null +++ b/.agents/skills/cavecrew/SKILL.md @@ -0,0 +1,82 @@ +--- +name: cavecrew +description: > + Decision guide for delegating to caveman-style subagents. Tells the main + thread WHEN to spawn `cavecrew-investigator` (locate code), `cavecrew-builder` + (1-2 file edit), or `cavecrew-reviewer` (diff review) instead of doing the + work inline or using vanilla `Explore`. Subagent output is caveman-compressed + so the tool-result injected back into main context is ~60% smaller — main + context lasts longer across long sessions. + Trigger: "delegate to subagent", "use cavecrew", "spawn investigator/builder/reviewer", + "save context", "compressed agent output". +--- + +Cavecrew = three subagent presets that emit caveman output. Same job as Anthropic defaults (`Explore`, edit-style agents, reviewer); difference is the tool-result they return is compressed, so main context shrinks per delegation. + +## When to use cavecrew vs alternatives + +| Task | Use | +|---|---| +| "Where is X defined / what calls Y / list uses of Z" | `cavecrew-investigator` | +| Same but you also want suggestions/architecture commentary | `Explore` (vanilla) | +| Surgical edit, ≤2 files, scope obvious | `cavecrew-builder` | +| New feature / 3+ files / cross-cutting refactor | Main thread or `feature-dev:code-architect` | +| Review diff, branch, or file for bugs | `cavecrew-reviewer` | +| Deep code review with rationale + alternatives | `Code Reviewer` (vanilla) | +| One-line answer you already know | Main thread, no subagent | + +Rule of thumb: **if you'd want the subagent's output in 1/3 the tokens, pick cavecrew. If you'd want prose, pick vanilla.** + +## Why this exists (the real win) + +Subagent tool results get injected into main context verbatim. A vanilla `Explore` that returns 2k tokens of prose costs 2k tokens of main-context budget every time. The same finding from `cavecrew-investigator` returns ~700 tokens. Across 20 delegations in one session that's the difference between context exhaustion and finishing the task. + +## Output contracts + +What main thread can rely on per agent: + +**`cavecrew-investigator`** +``` +
: +- path:line — `symbol` — short note +totals: . +``` +Or `No match.` Always file-path-first, line-number-attached, backticked symbols. Safe to grep with `path:\d+`. + +**`cavecrew-builder`** +``` +. +verified: . +``` +Or one of: `too-big.` / `needs-confirm.` / `ambiguous.` / `regressed.` (terminal first token). + +**`cavecrew-reviewer`** +``` +path:line: : . . +totals: N🔴 N🟡 N🔵 N❓ +``` +Or `No issues.` Findings sorted file → line ascending. + +## Chaining patterns + +**Locate → fix → verify** (most common): +1. `cavecrew-investigator` returns site list. +2. Main thread picks 1-2 sites, hands paths to `cavecrew-builder`. +3. `cavecrew-reviewer` audits the diff. + +**Parallel scout** (when investigation is broad): +Spawn 2-3 `cavecrew-investigator` calls in one message (different angles: defs vs callers vs tests). Aggregate in main thread. + +**Single-shot edit** (when site is already known): +Skip investigator. Hand exact path:line to `cavecrew-builder` directly. + +## What NOT to do + +- Don't use `cavecrew-builder` when you don't already know the file. Spawn investigator first or main thread will eat tokens passing context. +- Don't chain `cavecrew-investigator → cavecrew-builder` for a 5-file refactor. Builder will return `too-big.` and you'll have wasted a turn. +- Don't ask `cavecrew-reviewer` for "general feedback" — it returns findings only, no architecture opinions. Use `Code Reviewer` for that. +- Don't expect prose. Cavecrew output is structured, sometimes terse to the point of cryptic. If a human will read it directly, paraphrase. + +## Auto-clarity (inherited) + +Subagents drop caveman → normal English for security warnings, irreversible-action confirmations, and any output where fragment ambiguity could be misread. Resume caveman after. diff --git a/.agents/skills/caveman-commit/README.md b/.agents/skills/caveman-commit/README.md new file mode 100644 index 0000000..d5aee01 --- /dev/null +++ b/.agents/skills/caveman-commit/README.md @@ -0,0 +1,44 @@ +# caveman-commit + +Terse Conventional Commits. Why over what. + +## What it does + +Generates commit messages in Conventional Commits format. Subject ≤50 chars, hard cap 72. Imperative mood. Body only when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts — future debuggers need the context. + +Outputs only the message. Does not stage, commit, or amend. + +## How to invoke + +``` +/caveman-commit +``` + +Also triggers on phrases like "write a commit", "commit message", "generate commit". + +## Example output + +Diff: new endpoint for user profile. + +``` +feat(api): add GET /users/:id/profile + +Mobile client needs profile data without the full user payload +to reduce LTE bandwidth on cold-launch screens. + +Closes #128 +``` + +Diff: breaking API rename. + +``` +feat(api)!: rename /v1/orders to /v1/checkout + +BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout +before 2026-06-01. Old route returns 410 after that date. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-commit/SKILL.md b/.agents/skills/caveman-commit/SKILL.md new file mode 100644 index 0000000..729318c --- /dev/null +++ b/.agents/skills/caveman-commit/SKILL.md @@ -0,0 +1,65 @@ +--- +name: caveman-commit +description: > + Ultra-compressed commit message generator. Cuts noise from commit messages while preserving + intent and reasoning. Conventional Commits format. Subject ≤50 chars, body only when "why" + isn't obvious. Use when user says "write a commit", "commit message", "generate commit", + "/commit", or invokes /caveman-commit. Auto-triggers when staging changes. +--- + +Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what. + +## Rules + +**Subject line:** +- `(): ` — `` optional +- Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert` +- Imperative mood: "add", "fix", "remove" — not "added", "adds", "adding" +- ≤50 chars when possible, hard cap 72 +- No trailing period +- Match project convention for capitalization after the colon + +**Body (only if needed):** +- Skip entirely when subject is self-explanatory +- Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues +- Wrap at 72 chars +- Bullets `-` not `*` +- Reference issues/PRs at end: `Closes #42`, `Refs #17` + +**What NEVER goes in:** +- "This commit does X", "I", "we", "now", "currently" — the diff says what +- "As requested by..." — use Co-authored-by trailer +- "Generated with Claude Code" or any AI attribution +- Emoji (unless project convention requires) +- Restating the file name when scope already says it + +## Examples + +Diff: new endpoint for user profile with body explaining the why +- ❌ "feat: add a new endpoint to get user profile information from the database" +- ✅ + ``` + feat(api): add GET /users/:id/profile + + Mobile client needs profile data without the full user payload + to reduce LTE bandwidth on cold-launch screens. + + Closes #128 + ``` + +Diff: breaking API change +- ✅ + ``` + feat(api)!: rename /v1/orders to /v1/checkout + + BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout + before 2026-06-01. Old route returns 410 after that date. + ``` + +## Auto-Clarity + +Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never compress these into subject-only — future debuggers need the context. + +## Boundaries + +Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style. \ No newline at end of file diff --git a/.agents/skills/caveman-compress/README.md b/.agents/skills/caveman-compress/README.md new file mode 100644 index 0000000..b0fe65d --- /dev/null +++ b/.agents/skills/caveman-compress/README.md @@ -0,0 +1,163 @@ +

+ +

+ +

caveman-compress

+ +

+ shrink memory file. save token every session. +

+ +--- + +A Claude Code skill that compresses your project memory files (`CLAUDE.md`, todos, preferences) into caveman format — so every session loads fewer tokens automatically. + +Claude read `CLAUDE.md` on every session start. If file big, cost big. Caveman make file small. Cost go down forever. + +## What It Do + +``` +/caveman-compress CLAUDE.md +``` + +``` +CLAUDE.md ← compressed (Claude reads this — fewer tokens every session) +CLAUDE.original.md ← human-readable backup (you edit this) +``` + +Original never lost. You can read and edit `.original.md`. Run skill again to re-compress after edits. + +## Benchmarks + +Real results on real project files: + +| File | Original | Compressed | Saved | +|------|----------:|----------:|------:| +| `claude-md-preferences.md` | 706 | 285 | **59.6%** | +| `project-notes.md` | 1145 | 535 | **53.3%** | +| `claude-md-project.md` | 1122 | 636 | **43.3%** | +| `todo-list.md` | 627 | 388 | **38.1%** | +| `mixed-with-code.md` | 888 | 560 | **36.9%** | +| **Average** | **898** | **481** | **46%** | + +All validations passed ✅ — headings, code blocks, URLs, file paths preserved exactly. + +## Before / After + + + + + + +
+ +### 📄 Original (706 tokens) + +> "I strongly prefer TypeScript with strict mode enabled for all new code. Please don't use `any` type unless there's genuinely no way around it, and if you do, leave a comment explaining the reasoning. I find that taking the time to properly type things catches a lot of bugs before they ever make it to runtime." + + + +### 🪨 Caveman (285 tokens) + +> "Prefer TypeScript strict mode always. No `any` unless unavoidable — comment why if used. Proper types catch bugs early." + +
+ +**Same instructions. 60% fewer tokens. Every. Single. Session.** + +## Security + +`caveman-compress` is flagged as Snyk High Risk due to subprocess and file I/O patterns detected by static analysis. This is a false positive — see [SECURITY.md](./SECURITY.md) for a full explanation of what the skill does and does not do. + +## Install + +Compress is built in with the `caveman` plugin. Install `caveman` once, then use `/caveman-compress`. + +If you need local files, the compress skill lives at: + +```bash +caveman-compress/ +``` + +**Requires:** Python 3.10+ + +## Usage + +``` +/caveman-compress +``` + +Examples: +``` +/caveman-compress CLAUDE.md +/caveman-compress docs/preferences.md +/caveman-compress todos.md +``` + +### What files work + +| Type | Compress? | +|------|-----------| +| `.md`, `.txt`, `.rst`, `.typ`, `.typst`, `.tex` | ✅ Yes | +| Extensionless natural language | ✅ Yes | +| `.py`, `.js`, `.ts`, `.json`, `.yaml` | ❌ Skip (code/config) | +| `*.original.md` | ❌ Skip (backup files) | + +## How It Work + +``` +/caveman-compress CLAUDE.md + ↓ +detect file type (no tokens) + ↓ +Claude compresses (tokens — one call) + ↓ +validate output (no tokens) + checks: headings, code blocks, URLs, file paths, bullets + ↓ +if errors: Claude fixes cherry-picked issues only (tokens — targeted fix) + does NOT recompress — only patches broken parts + ↓ +retry up to 2 times + ↓ +write compressed → CLAUDE.md +write original → CLAUDE.original.md +``` + +Only two things use tokens: initial compression + targeted fix if validation fails. Everything else is local Python. + +## What Is Preserved + +Caveman compress natural language. It never touch: + +- Code blocks (` ``` ` fenced or indented) +- Inline code (`` `backtick content` ``) +- URLs and links +- File paths (`/src/components/...`) +- Commands (`npm install`, `git commit`) +- Technical terms, library names, API names +- Headings (exact text preserved) +- Tables (structure preserved, cell text compressed) +- Dates, version numbers, numeric values + +## Why This Matter + +`CLAUDE.md` loads on **every session start**. A 1000-token project memory file costs tokens every single time you open a project. Over 100 sessions that's 100,000 tokens of overhead — just for context you already wrote. + +Caveman cut that by ~46% on average. Same instructions. Same accuracy. Less waste. + +``` +┌────────────────────────────────────────────┐ +│ TOKEN SAVINGS PER FILE █████ 46% │ +│ SESSIONS THAT BENEFIT ██████████ 100% │ +│ INFORMATION PRESERVED ██████████ 100% │ +│ SETUP TIME █ 1x │ +└────────────────────────────────────────────┘ +``` + +## Part of Caveman + +This skill is part of the [caveman](https://github.com/JuliusBrussee/caveman) toolkit — making Claude use fewer tokens without losing accuracy. + +- **caveman** — make Claude *speak* like caveman (cuts response tokens ~65%) +- **caveman-compress** — make Claude *read* less (cuts context tokens ~46%) diff --git a/.agents/skills/caveman-compress/SECURITY.md b/.agents/skills/caveman-compress/SECURITY.md new file mode 100644 index 0000000..693108c --- /dev/null +++ b/.agents/skills/caveman-compress/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +## Snyk High Risk Rating + +`caveman-compress` receives a Snyk High Risk rating due to static analysis heuristics. This document explains what the skill does and does not do. + +### What triggers the rating + +1. **subprocess usage**: The skill calls the `claude` CLI via `subprocess.run()` as a fallback when `ANTHROPIC_API_KEY` is not set. The subprocess call uses a fixed argument list — no shell interpolation occurs. User file content is passed via stdin, not as a shell argument. + +2. **File read/write**: The skill reads the file the user explicitly points it at, compresses it, and writes the result back to the same path. A `.original.md` backup is saved alongside it. No files outside the user-specified path are read or written. + +### What the skill does NOT do + +- Does not execute user file content as code +- Does not make network requests except to Anthropic's API (via SDK or CLI) +- Does not access files outside the path the user provides +- Does not use shell=True or string interpolation in subprocess calls +- Does not collect or transmit any data beyond the file being compressed + +### Auth behavior + +If `ANTHROPIC_API_KEY` is set, the skill uses the Anthropic Python SDK directly (no subprocess). If not set, it falls back to the `claude` CLI, which uses the user's existing Claude desktop authentication. + +### File size limit + +Files larger than 500KB are rejected before any API call is made. + +### Reporting a vulnerability + +If you believe you've found a genuine security issue, please open a GitHub issue with the label `security`. diff --git a/.agents/skills/caveman-compress/SKILL.md b/.agents/skills/caveman-compress/SKILL.md new file mode 100644 index 0000000..00ce454 --- /dev/null +++ b/.agents/skills/caveman-compress/SKILL.md @@ -0,0 +1,111 @@ +--- +name: caveman-compress +description: > + Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format + to save input tokens. Preserves all technical substance, code, URLs, and structure. + Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md. + Trigger: /caveman-compress FILEPATH or "compress memory file" +--- + +# Caveman Compress + +## Purpose + +Compress natural language files (CLAUDE.md, todos, preferences) into caveman-speak to reduce input tokens. Compressed version overwrites original. Human-readable backup saved as `.original.md`. + +## Trigger + +`/caveman-compress ` or when user asks to compress a memory file. + +## Process + +1. The compression scripts live in `scripts/` (adjacent to this SKILL.md). If the path is not immediately available, search for `scripts/__main__.py` next to this SKILL.md. + +2. From the directory containing this SKILL.md, run: + +python3 -m scripts + +3. The CLI will: +- detect file type (no tokens) +- call Claude to compress +- validate output (no tokens) +- if errors: cherry-pick fix with Claude (targeted fixes only, no recompression) +- retry up to 2 times +- if still failing after 2 retries: report error to user, leave original file untouched + +4. Return result to user + +## Compression Rules + +### Remove +- Articles: a, an, the +- Filler: just, really, basically, actually, simply, essentially, generally +- Pleasantries: "sure", "certainly", "of course", "happy to", "I'd recommend" +- Hedging: "it might be worth", "you could consider", "it would be good to" +- Redundant phrasing: "in order to" → "to", "make sure to" → "ensure", "the reason is because" → "because" +- Connective fluff: "however", "furthermore", "additionally", "in addition" + +### Preserve EXACTLY (never modify) +- Code blocks (fenced ``` and indented) +- Inline code (`backtick content`) +- URLs and links (full URLs, markdown links) +- File paths (`/src/components/...`, `./config.yaml`) +- Commands (`npm install`, `git commit`, `docker build`) +- Technical terms (library names, API names, protocols, algorithms) +- Proper nouns (project names, people, companies) +- Dates, version numbers, numeric values +- Environment variables (`$HOME`, `NODE_ENV`) + +### Preserve Structure +- All markdown headings (keep exact heading text, compress body below) +- Bullet point hierarchy (keep nesting level) +- Numbered lists (keep numbering) +- Tables (compress cell text, keep structure) +- Frontmatter/YAML headers in markdown files + +### Compress +- Use short synonyms: "big" not "extensive", "fix" not "implement a solution for", "use" not "utilize" +- Fragments OK: "Run tests before commit" not "You should always run tests before committing" +- Drop "you should", "make sure to", "remember to" — just state the action +- Merge redundant bullets that say the same thing differently +- Keep one example where multiple examples show the same pattern + +CRITICAL RULE: +Anything inside ``` ... ``` must be copied EXACTLY. +Do not: +- remove comments +- remove spacing +- reorder lines +- shorten commands +- simplify anything + +Inline code (`...`) must be preserved EXACTLY. +Do not modify anything inside backticks. + +If file contains code blocks: +- Treat code blocks as read-only regions +- Only compress text outside them +- Do not merge sections around code + +## Pattern + +Original: +> You should always make sure to run the test suite before pushing any changes to the main branch. This is important because it helps catch bugs early and prevents broken builds from being deployed to production. + +Compressed: +> Run tests before push to main. Catch bugs early, prevent broken prod deploys. + +Original: +> The application uses a microservices architecture with the following components. The API gateway handles all incoming requests and routes them to the appropriate service. The authentication service is responsible for managing user sessions and JWT tokens. + +Compressed: +> Microservices architecture. API gateway route all requests to services. Auth service manage user sessions + JWT tokens. + +## Boundaries + +- ONLY compress natural language files (.md, .txt, .typ, .typst, .tex, extensionless) +- NEVER modify: .py, .js, .ts, .json, .yaml, .yml, .toml, .env, .lock, .css, .html, .xml, .sql, .sh +- If file has mixed content (prose + code), compress ONLY the prose sections +- If unsure whether something is code or prose, leave it unchanged +- Original file is backed up as FILE.original.md before overwriting +- Never compress FILE.original.md (skip it) diff --git a/.agents/skills/caveman-compress/scripts/__init__.py b/.agents/skills/caveman-compress/scripts/__init__.py new file mode 100644 index 0000000..16b8c53 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/__init__.py @@ -0,0 +1,9 @@ +"""Caveman compress scripts. + +This package provides tools to compress natural language markdown files +into caveman format to save input tokens. +""" + +__all__ = ["cli", "compress", "detect", "validate"] + +__version__ = "1.0.0" diff --git a/.agents/skills/caveman-compress/scripts/__main__.py b/.agents/skills/caveman-compress/scripts/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/.agents/skills/caveman-compress/scripts/benchmark.py b/.agents/skills/caveman-compress/scripts/benchmark.py new file mode 100644 index 0000000..f9e2ee0 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/benchmark.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +# Support both direct execution and module import +try: + from .validate import validate +except ImportError: + sys.path.insert(0, str(Path(__file__).parent)) + from validate import validate + +try: + import tiktoken + _enc = tiktoken.get_encoding("o200k_base") +except ImportError: + _enc = None + + +def count_tokens(text): + if _enc is None: + return len(text.split()) # fallback: word count + return len(_enc.encode(text)) + + +def benchmark_pair(orig_path: Path, comp_path: Path): + orig_text = orig_path.read_text() + comp_text = comp_path.read_text() + + orig_tokens = count_tokens(orig_text) + comp_tokens = count_tokens(comp_text) + saved = 100 * (orig_tokens - comp_tokens) / orig_tokens if orig_tokens > 0 else 0.0 + result = validate(orig_path, comp_path) + + return (comp_path.name, orig_tokens, comp_tokens, saved, result.is_valid) + + +def print_table(rows): + print("\n| File | Original | Compressed | Saved % | Valid |") + print("|------|----------|------------|---------|-------|") + for r in rows: + print(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]:.1f}% | {'✅' if r[4] else '❌'} |") + + +def main(): + # Direct file pair: python3 benchmark.py original.md compressed.md + if len(sys.argv) == 3: + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + if not orig.exists(): + print(f"❌ Not found: {orig}") + sys.exit(1) + if not comp.exists(): + print(f"❌ Not found: {comp}") + sys.exit(1) + print_table([benchmark_pair(orig, comp)]) + return + + # Glob mode: repo_root/tests/caveman-compress/ + # __file__ lives at /skills/caveman-compress/scripts/benchmark.py + # Walk up four dirs: scripts → caveman-compress → skills → repo_root. + tests_dir = Path(__file__).resolve().parents[3] / "tests" / "caveman-compress" + if not tests_dir.exists(): + print(f"❌ Tests dir not found: {tests_dir}") + sys.exit(1) + + rows = [] + for orig in sorted(tests_dir.glob("*.original.md")): + comp = orig.with_name(orig.stem.removesuffix(".original") + ".md") + if comp.exists(): + rows.append(benchmark_pair(orig, comp)) + + if not rows: + print("No compressed file pairs found.") + return + + print_table(rows) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/caveman-compress/scripts/cli.py b/.agents/skills/caveman-compress/scripts/cli.py new file mode 100644 index 0000000..c314f87 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/cli.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Caveman Compress CLI + +Usage: + caveman +""" + +import sys + +# Force UTF-8 on stdout/stderr before any code can print. Windows consoles +# default to cp1252 and crash on the ❌ glyphs in error/validation branches, +# masking the real error and leaving the user with a half-compressed file. +for _stream in (sys.stdout, sys.stderr): + reconfigure = getattr(_stream, "reconfigure", None) + if callable(reconfigure): + try: + reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from pathlib import Path + +from .compress import compress_file +from .detect import detect_file_type, should_compress + + +def print_usage(): + print("Usage: caveman ") + + +def main(): + if len(sys.argv) != 2: + print_usage() + sys.exit(1) + + filepath = Path(sys.argv[1]) + + # Check file exists + if not filepath.exists(): + print(f"❌ File not found: {filepath}") + sys.exit(1) + + if not filepath.is_file(): + print(f"❌ Not a file: {filepath}") + sys.exit(1) + + filepath = filepath.resolve() + + # Detect file type + file_type = detect_file_type(filepath) + + print(f"Detected: {file_type}") + + # Check if compressible + if not should_compress(filepath): + print("Skipping: file is not natural language (code/config)") + sys.exit(0) + + print("Starting caveman compression...\n") + + try: + success = compress_file(filepath) + + if success: + print("\nCompression completed successfully") + backup_path = filepath.with_name(filepath.stem + ".original.md") + print(f"Compressed: {filepath}") + print(f"Original: {backup_path}") + sys.exit(0) + else: + print("\n❌ Compression failed after retries") + sys.exit(2) + + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(130) + + except Exception as e: + print(f"\n❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/caveman-compress/scripts/compress.py b/.agents/skills/caveman-compress/scripts/compress.py new file mode 100644 index 0000000..7adc4fb --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/compress.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Caveman Memory Compression Orchestrator + +Usage: + python scripts/compress.py +""" + +import os +import re +import subprocess +from pathlib import Path +from typing import List + +OUTER_FENCE_REGEX = re.compile( + r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL +) + +# Filenames and paths that almost certainly hold secrets or PII. Compressing +# them ships raw bytes to the Anthropic API — a third-party data boundary that +# developers on sensitive codebases cannot cross. detect.py already skips .env +# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would +# slip through the natural-language filter. This is a hard refuse before read. +SENSITIVE_BASENAME_REGEX = re.compile( + r"(?ix)^(" + r"\.env(\..+)?" + r"|\.netrc" + r"|credentials(\..+)?" + r"|secrets?(\..+)?" + r"|passwords?(\..+)?" + r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?" + r"|authorized_keys" + r"|known_hosts" + r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)" + r")$" +) + +SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"}) + +SENSITIVE_NAME_TOKENS = ( + "secret", "credential", "password", "passwd", + "apikey", "accesskey", "token", "privatekey", +) + + +def is_sensitive_path(filepath: Path) -> bool: + """Heuristic denylist for files that must never be shipped to a third-party API.""" + name = filepath.name + if SENSITIVE_BASENAME_REGEX.match(name): + return True + lowered_parts = {p.lower() for p in filepath.parts} + if lowered_parts & SENSITIVE_PATH_COMPONENTS: + return True + # Normalize separators so "api-key" and "api_key" both match "apikey". + lower = re.sub(r"[_\-\s.]", "", name.lower()) + return any(tok in lower for tok in SENSITIVE_NAME_TOKENS) + + +def strip_llm_wrapper(text: str) -> str: + """Strip outer ```markdown ... ``` fence when it wraps the entire output.""" + m = OUTER_FENCE_REGEX.match(text) + if m: + return m.group(2) + return text + +from .detect import should_compress +from .validate import validate + +MAX_RETRIES = 2 + + +# ---------- Claude Calls ---------- + + +def call_claude(prompt: str) -> str: + api_key = os.environ.get("ANTHROPIC_API_KEY") + if api_key: + try: + import anthropic + + client = anthropic.Anthropic(api_key=api_key) + msg = client.messages.create( + model=os.environ.get("CAVEMAN_MODEL", "claude-sonnet-4-5"), + max_tokens=8192, + messages=[{"role": "user", "content": prompt}], + ) + return strip_llm_wrapper(msg.content[0].text.strip()) + except ImportError: + pass # anthropic not installed, fall back to CLI + # Fallback: use claude CLI (handles desktop auth) + try: + result = subprocess.run( + ["claude", "--print"], + input=prompt, + text=True, + capture_output=True, + check=True, + ) + return strip_llm_wrapper(result.stdout.strip()) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Claude call failed:\n{e.stderr}") + + +def build_compress_prompt(original: str) -> str: + return f""" +Compress this markdown into caveman format. + +STRICT RULES: +- Do NOT modify anything inside ``` code blocks +- Do NOT modify anything inside inline backticks +- Preserve ALL URLs exactly +- Preserve ALL headings exactly +- Preserve file paths and commands +- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. + +Only compress natural language. + +TEXT: +{original} +""" + + +def build_fix_prompt(original: str, compressed: str, errors: List[str]) -> str: + errors_str = "\n".join(f"- {e}" for e in errors) + return f"""You are fixing a caveman-compressed markdown file. Specific validation errors were found. + +CRITICAL RULES: +- DO NOT recompress or rephrase the file +- ONLY fix the listed errors — leave everything else exactly as-is +- The ORIGINAL is provided as reference only (to restore missing content) +- Preserve caveman style in all untouched sections + +ERRORS TO FIX: +{errors_str} + +HOW TO FIX: +- Missing URL: find it in ORIGINAL, restore it exactly where it belongs in COMPRESSED +- Code block mismatch: find the exact code block in ORIGINAL, restore it in COMPRESSED +- Heading mismatch: restore the exact heading text from ORIGINAL into COMPRESSED +- Do not touch any section not mentioned in the errors + +ORIGINAL (reference only): +{original} + +COMPRESSED (fix this): +{compressed} + +Return ONLY the fixed compressed file. No explanation. +""" + + +# ---------- Core Logic ---------- + + +def compress_file(filepath: Path) -> bool: + # Resolve and validate path + filepath = filepath.resolve() + MAX_FILE_SIZE = 500_000 # 500KB + if not filepath.exists(): + raise FileNotFoundError(f"File not found: {filepath}") + if filepath.stat().st_size > MAX_FILE_SIZE: + raise ValueError(f"File too large to compress safely (max 500KB): {filepath}") + + # Refuse files that look like they contain secrets or PII. Compressing ships + # the raw bytes to the Anthropic API — a third-party boundary — so we fail + # loudly rather than silently exfiltrate credentials or keys. Override is + # intentional: the user must rename the file if the heuristic is wrong. + if is_sensitive_path(filepath): + raise ValueError( + f"Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Compression sends file contents to the Anthropic API. " + "Rename the file if this is a false positive." + ) + + print(f"Processing: {filepath}") + + if not should_compress(filepath): + print("Skipping (not natural language)") + return False + + original_text = filepath.read_text(errors="ignore") + backup_path = filepath.with_name(filepath.stem + ".original.md") + + if not original_text.strip(): + print("❌ Refusing to compress: file is empty or whitespace-only.") + return False + + # Check if backup already exists to prevent accidental overwriting + if backup_path.exists(): + print(f"⚠️ Backup file already exists: {backup_path}") + print("The original backup may contain important content.") + print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + return False + + # Step 1: Compress + print("Compressing with Claude...") + compressed = call_claude(build_compress_prompt(original_text)) + + if compressed is None or not compressed.strip(): + print("❌ Compression aborted: Claude returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + + if compressed.strip() == original_text.strip(): + print("❌ Compression aborted: output is identical to input.") + print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is") + print(" already in caveman form. Original file is untouched (no backup created).") + return False + + # Save original as backup, then verify the backup readback before + # touching the input file. If the filesystem dropped bytes (encoding, + # antivirus, disk full), unlink the bad backup and abort instead of + # leaving the user with a corrupt backup + compressed primary. + backup_path.write_text(original_text) + backup_readback = backup_path.read_text(errors="ignore") + if backup_readback != original_text: + print(f"❌ Backup write verification failed: {backup_path}") + print(" In-memory original differs from on-disk backup. Aborting before touching the input file.") + try: + backup_path.unlink() + except OSError: + pass + return False + filepath.write_text(compressed) + + # Step 2: Validate + Retry + for attempt in range(MAX_RETRIES): + print(f"\nValidation attempt {attempt + 1}") + + result = validate(backup_path, filepath) + + if result.is_valid: + print("Validation passed") + break + + print("❌ Validation failed:") + for err in result.errors: + print(f" - {err}") + + if attempt == MAX_RETRIES - 1: + # Restore original on failure + filepath.write_text(original_text) + backup_path.unlink(missing_ok=True) + print("❌ Failed after retries — original restored") + return False + + print("Fixing with Claude...") + compressed = call_claude( + build_fix_prompt(original_text, compressed, result.errors) + ) + filepath.write_text(compressed) + + return True diff --git a/.agents/skills/caveman-compress/scripts/detect.py b/.agents/skills/caveman-compress/scripts/detect.py new file mode 100644 index 0000000..8d5f6d7 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/detect.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Detect whether a file is natural language (compressible) or code/config (skip).""" + +import json +import re +from pathlib import Path + +# Extensions that are natural language and compressible +COMPRESSIBLE_EXTENSIONS = {".md", ".txt", ".markdown", ".rst", ".typ", ".typst", ".tex"} + +# Extensions that are code/config and should be skipped +SKIP_EXTENSIONS = { + ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml", + ".toml", ".env", ".lock", ".css", ".scss", ".html", ".xml", + ".sql", ".sh", ".bash", ".zsh", ".go", ".rs", ".java", ".c", + ".cpp", ".h", ".hpp", ".rb", ".php", ".swift", ".kt", ".lua", + ".dockerfile", ".makefile", ".csv", ".ini", ".cfg", +} + +# Patterns that indicate a line is code +CODE_PATTERNS = [ + re.compile(r"^\s*(import |from .+ import |require\(|const |let |var )"), + re.compile(r"^\s*(def |class |function |async function |export )"), + re.compile(r"^\s*(if\s*\(|for\s*\(|while\s*\(|switch\s*\(|try\s*\{)"), + re.compile(r"^\s*[\}\]\);]+\s*$"), # closing braces/brackets + re.compile(r"^\s*@\w+"), # decorators/annotations + re.compile(r'^\s*"[^"]+"\s*:\s*'), # JSON-like key-value + re.compile(r"^\s*\w+\s*=\s*[{\[\(\"']"), # assignment with literal +] + + +def _is_code_line(line: str) -> bool: + """Check if a line looks like code.""" + return any(p.match(line) for p in CODE_PATTERNS) + + +def _is_json_content(text: str) -> bool: + """Check if content is valid JSON.""" + try: + json.loads(text) + return True + except (json.JSONDecodeError, ValueError): + return False + + +def _is_yaml_content(lines: list[str]) -> bool: + """Heuristic: check if content looks like YAML.""" + yaml_indicators = 0 + for line in lines[:30]: + stripped = line.strip() + if stripped.startswith("---"): + yaml_indicators += 1 + elif re.match(r"^\w[\w\s]*:\s", stripped): + yaml_indicators += 1 + elif stripped.startswith("- ") and ":" in stripped: + yaml_indicators += 1 + # If most non-empty lines look like YAML + non_empty = sum(1 for l in lines[:30] if l.strip()) + return non_empty > 0 and yaml_indicators / non_empty > 0.6 + + +def detect_file_type(filepath: Path) -> str: + """Classify a file as 'natural_language', 'code', 'config', or 'unknown'. + + Returns: + One of: 'natural_language', 'code', 'config', 'unknown' + """ + ext = filepath.suffix.lower() + + # Extension-based classification + if ext in COMPRESSIBLE_EXTENSIONS: + return "natural_language" + if ext in SKIP_EXTENSIONS: + return "code" if ext not in {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".env"} else "config" + + # Extensionless files (like CLAUDE.md, TODO) — check content + if not ext: + try: + text = filepath.read_text(errors="ignore") + except (OSError, PermissionError): + return "unknown" + + lines = text.splitlines()[:50] + + if _is_json_content(text[:10000]): + return "config" + if _is_yaml_content(lines): + return "config" + + code_lines = sum(1 for l in lines if l.strip() and _is_code_line(l)) + non_empty = sum(1 for l in lines if l.strip()) + if non_empty > 0 and code_lines / non_empty > 0.4: + return "code" + + return "natural_language" + + return "unknown" + + +def should_compress(filepath: Path) -> bool: + """Return True if the file is natural language and should be compressed.""" + if not filepath.is_file(): + return False + # Skip backup files + if filepath.name.endswith(".original.md"): + return False + return detect_file_type(filepath) == "natural_language" + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python detect.py [file2] ...") + sys.exit(1) + + for path_str in sys.argv[1:]: + p = Path(path_str).resolve() + file_type = detect_file_type(p) + compress = should_compress(p) + print(f" {p.name:30s} type={file_type:20s} compress={compress}") diff --git a/.agents/skills/caveman-compress/scripts/validate.py b/.agents/skills/caveman-compress/scripts/validate.py new file mode 100644 index 0000000..dc07307 --- /dev/null +++ b/.agents/skills/caveman-compress/scripts/validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +import re +from collections import Counter +from pathlib import Path + +URL_REGEX = re.compile(r"https?://[^\s)]+") +FENCE_OPEN_REGEX = re.compile(r"^(\s{0,3})(`{3,}|~{3,})(.*)$") +HEADING_REGEX = re.compile(r"^(#{1,6})\s+(.*)", re.MULTILINE) +BULLET_REGEX = re.compile(r"^\s*[-*+]\s+", re.MULTILINE) + +# crude but effective path detection +# Requires either a path prefix (./ ../ / or drive letter) or a slash/backslash within the match +PATH_REGEX = re.compile(r"(?:\./|\.\./|/|[A-Za-z]:\\)[\w\-/\\\.]+|[\w\-\.]+[/\\][\w\-/\\\.]+") + + +class ValidationResult: + def __init__(self): + self.is_valid = True + self.errors = [] + self.warnings = [] + + def add_error(self, msg): + self.is_valid = False + self.errors.append(msg) + + def add_warning(self, msg): + self.warnings.append(msg) + + +def read_file(path: Path) -> str: + return path.read_text(errors="ignore") + + +# ---------- Extractors ---------- + + +def extract_headings(text): + return [(level, title.strip()) for level, title in HEADING_REGEX.findall(text)] + + +def extract_code_blocks(text): + """Line-based fenced code block extractor. + + Handles ``` and ~~~ fences with variable length (CommonMark: closing + fence must use same char and be at least as long as opening). Supports + nested fences (e.g. an outer 4-backtick block wrapping inner 3-backtick + content). + """ + blocks = [] + lines = text.split("\n") + i = 0 + n = len(lines) + while i < n: + m = FENCE_OPEN_REGEX.match(lines[i]) + if not m: + i += 1 + continue + fence_char = m.group(2)[0] + fence_len = len(m.group(2)) + open_line = lines[i] + block_lines = [open_line] + i += 1 + closed = False + while i < n: + close_m = FENCE_OPEN_REGEX.match(lines[i]) + if ( + close_m + and close_m.group(2)[0] == fence_char + and len(close_m.group(2)) >= fence_len + and close_m.group(3).strip() == "" + ): + block_lines.append(lines[i]) + closed = True + i += 1 + break + block_lines.append(lines[i]) + i += 1 + if closed: + blocks.append("\n".join(block_lines)) + # Unclosed fences are silently skipped — they indicate malformed markdown + # and including them would cause false-positive validation failures. + return blocks + + +def extract_urls(text): + return set(URL_REGEX.findall(text)) + + +def extract_paths(text): + return set(PATH_REGEX.findall(text)) + + +def count_bullets(text): + return len(BULLET_REGEX.findall(text)) + + +def extract_inline_codes(text): + text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE) + text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE) + return re.findall(r"`([^`]+)`", text_without_fences) + + +# ---------- Validators ---------- + + +def validate_headings(orig, comp, result): + h1 = extract_headings(orig) + h2 = extract_headings(comp) + + if len(h1) != len(h2): + result.add_error(f"Heading count mismatch: {len(h1)} vs {len(h2)}") + + if h1 != h2: + result.add_warning("Heading text/order changed") + + +def validate_code_blocks(orig, comp, result): + c1 = extract_code_blocks(orig) + c2 = extract_code_blocks(comp) + + if c1 != c2: + result.add_error("Code blocks not preserved exactly") + + +def validate_urls(orig, comp, result): + u1 = extract_urls(orig) + u2 = extract_urls(comp) + + if u1 != u2: + result.add_error(f"URL mismatch: lost={u1 - u2}, added={u2 - u1}") + + +def validate_paths(orig, comp, result): + p1 = extract_paths(orig) + p2 = extract_paths(comp) + + if p1 != p2: + result.add_warning(f"Path mismatch: lost={p1 - p2}, added={p2 - p1}") + + +def validate_bullets(orig, comp, result): + b1 = count_bullets(orig) + b2 = count_bullets(comp) + + if b1 == 0: + return + + diff = abs(b1 - b2) / b1 + + if diff > 0.15: + result.add_warning(f"Bullet count changed too much: {b1} -> {b2}") + + +def validate_inline_codes(orig, comp, result): + c1 = Counter(extract_inline_codes(orig)) + c2 = Counter(extract_inline_codes(comp)) + + if c1 != c2: + lost = set(c1.keys()) - set(c2.keys()) + added = set(c2.keys()) - set(c1.keys()) + for code, count in c1.items(): + if code in c2 and c2[code] < count: + lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)") + if lost: + result.add_error(f"Inline code lost: {lost}") + if added: + result.add_warning(f"Inline code added: {added}") + + +# ---------- Main ---------- + + +def validate(original_path: Path, compressed_path: Path) -> ValidationResult: + result = ValidationResult() + + orig = read_file(original_path) + comp = read_file(compressed_path) + + validate_headings(orig, comp, result) + validate_code_blocks(orig, comp, result) + validate_urls(orig, comp, result) + validate_paths(orig, comp, result) + validate_bullets(orig, comp, result) + validate_inline_codes(orig, comp, result) + + return result + + +# ---------- CLI ---------- + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 3: + print("Usage: python validate.py ") + sys.exit(1) + + orig = Path(sys.argv[1]).resolve() + comp = Path(sys.argv[2]).resolve() + + res = validate(orig, comp) + + print(f"\nValid: {res.is_valid}") + + if res.errors: + print("\nErrors:") + for e in res.errors: + print(f" - {e}") + + if res.warnings: + print("\nWarnings:") + for w in res.warnings: + print(f" - {w}") diff --git a/.agents/skills/caveman-help/README.md b/.agents/skills/caveman-help/README.md new file mode 100644 index 0000000..5841256 --- /dev/null +++ b/.agents/skills/caveman-help/README.md @@ -0,0 +1,38 @@ +# caveman-help + +Quick-reference card. One shot, no mode change. + +## What it does + +Prints a cheat sheet of all caveman modes, sibling skills, deactivation triggers, and how to set the default mode via env var or config file. One-shot display — does not flip the active mode, write flag files, or persist anything. Use when you forget the slash commands. + +## How to invoke + +``` +/caveman-help +``` + +Also triggers on "caveman help", "what caveman commands", "how do I use caveman". + +## Example output + +``` +Modes: + /caveman full (default) + /caveman lite lighter + /caveman ultra extreme + /caveman wenyan classical Chinese + +Skills: + /caveman-commit terse Conventional Commits + /caveman-review one-line PR comments + /caveman-stats session token savings + +Deactivate: + "stop caveman" or "normal mode" +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full reference card +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-help/SKILL.md b/.agents/skills/caveman-help/SKILL.md new file mode 100644 index 0000000..252221f --- /dev/null +++ b/.agents/skills/caveman-help/SKILL.md @@ -0,0 +1,59 @@ +--- +name: caveman-help +description: > + Quick-reference card for all caveman modes, skills, and commands. + One-shot display, not a persistent mode. Trigger: /caveman-help, + "caveman help", "what caveman commands", "how do I use caveman". +--- + +# Caveman Help + +Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style. + +## Modes + +| Mode | Trigger | What change | +|------|---------|-------------| +| **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. | +| **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. | +| **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. | +| **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. | +| **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. | +| **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. | + +Mode stick until changed or session end. + +## Skills + +| Skill | Trigger | What it do | +|-------|---------|-----------| +| **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. | +| **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` | +| **caveman-compress** | `/caveman-compress ` | Compress .md files to caveman prose. Saves ~46% input tokens. | +| **caveman-help** | `/caveman-help` | This card. | + +## Deactivate + +Say "stop caveman" or "normal mode". Resume anytime with `/caveman`. + +## Configure Default Mode + +Default mode = `full`. Change it: + +**Environment variable** (highest priority): +```bash +export CAVEMAN_DEFAULT_MODE=ultra +``` + +**Config file** (`~/.config/caveman/config.json`): +```json +{ "defaultMode": "lite" } +``` + +Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`. + +Resolution: env var > config file > `full`. + +## More + +Full docs: https://github.com/JuliusBrussee/caveman diff --git a/.agents/skills/caveman-review/README.md b/.agents/skills/caveman-review/README.md new file mode 100644 index 0000000..acf519f --- /dev/null +++ b/.agents/skills/caveman-review/README.md @@ -0,0 +1,33 @@ +# caveman-review + +One-line PR comments. Location, problem, fix. No throat-clearing. + +## What it does + +Generates code review comments in `L: . .` format. One line per finding. Severity emoji: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps exact line numbers, backticked symbols, and concrete fixes. + +Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts where the author needs the *why*. Resumes terse for the rest. + +Output only — does not approve, request changes, or run linters. + +## How to invoke + +``` +/caveman-review +``` + +Also triggers on "review this PR", "code review", "review the diff". + +## Example output + +``` +L42: 🔴 bug: user can be null after .find(). Add guard before .email. +L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist. +L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3). +L107: ❓ q: why drop the cache here? Reads on next request will miss. +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-review/SKILL.md b/.agents/skills/caveman-review/SKILL.md new file mode 100644 index 0000000..48f4adb --- /dev/null +++ b/.agents/skills/caveman-review/SKILL.md @@ -0,0 +1,55 @@ +--- +name: caveman-review +description: > + Ultra-compressed code review comments. Cuts noise from PR feedback while preserving + the actionable signal. Each comment is one line: location, problem, fix. Use when user + says "review this PR", "code review", "review the diff", "/review", or invokes + /caveman-review. Auto-triggers when reviewing pull requests. +--- + +Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing. + +## Rules + +**Format:** `L: . .` — or `:L: ...` when reviewing multi-file diffs. + +**Severity prefix (optional, when mixed):** +- `🔴 bug:` — broken behavior, will cause incident +- `🟡 risk:` — works but fragile (race, missing null check, swallowed error) +- `🔵 nit:` — style, naming, micro-optim. Author can ignore +- `❓ q:` — genuine question, not a suggestion + +**Drop:** +- "I noticed that...", "It seems like...", "You might want to consider..." +- "This is just a suggestion but..." — use `nit:` instead +- "Great work!", "Looks good overall but..." — say it once at the top, not per comment +- Restating what the line does — the reviewer can read the diff +- Hedging ("perhaps", "maybe", "I think") — if unsure use `q:` + +**Keep:** +- Exact line numbers +- Exact symbol/function/variable names in backticks +- Concrete fix, not "consider refactoring this" +- The *why* if the fix isn't obvious from the problem statement + +## Examples + +❌ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This could potentially cause a crash if the user is not found in the database. You might want to add a null check here." + +✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.` + +❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability." + +✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.` + +❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case." + +✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).` + +## Auto-Clarity + +Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those cases write a normal paragraph, then resume terse for the rest. + +## Boundaries + +Reviews only — does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style. \ No newline at end of file diff --git a/.agents/skills/caveman-stats/README.md b/.agents/skills/caveman-stats/README.md new file mode 100644 index 0000000..30b28f9 --- /dev/null +++ b/.agents/skills/caveman-stats/README.md @@ -0,0 +1,30 @@ +# caveman-stats + +Real session token receipts. No AI estimation. + +## What it does + +Reads the current Claude Code session log directly and reports actual input/output token usage plus estimated savings versus a non-caveman baseline. Numbers come from the JSONL session log on disk — the model itself does not compute or estimate them. Output is injected by the `caveman-mode-tracker` hook, which intercepts `/caveman-stats` and returns the formatted stats as a blocked-decision reason. + +Each run also writes a lifetime-savings suffix file used by the statusline badge (`⛏ 12.4k`). + +## How to invoke + +``` +/caveman-stats +``` + +## Example output + +``` +Session: 47 turns +Input: 12,304 tokens +Output: 3,891 tokens (caveman) +Baseline: 11,247 tokens (estimated without caveman) +Saved: 7,356 tokens (~65%) +``` + +## See also + +- [`SKILL.md`](./SKILL.md) — hook contract and mechanics +- [Caveman README](../../README.md) — repo overview diff --git a/.agents/skills/caveman-stats/SKILL.md b/.agents/skills/caveman-stats/SKILL.md new file mode 100644 index 0000000..a7348ac --- /dev/null +++ b/.agents/skills/caveman-stats/SKILL.md @@ -0,0 +1,10 @@ +--- +name: caveman-stats +description: > + Show real token usage and estimated savings for the current session. + Reads directly from the Claude Code session log — no AI estimation. + Triggers on /caveman-stats. Output is injected by the mode-tracker hook; + the model itself does not compute the numbers. +--- + +This skill is delivered by `hooks/caveman-stats.js` (read by `hooks/caveman-mode-tracker.js` on `/caveman-stats`). The model does not need to do anything when this skill fires — the hook returns `decision: "block"` with the formatted stats as the reason. The user sees the numbers immediately. diff --git a/.agents/skills/caveman/README.md b/.agents/skills/caveman/README.md new file mode 100644 index 0000000..d749b83 --- /dev/null +++ b/.agents/skills/caveman/README.md @@ -0,0 +1,48 @@ +# caveman + +Talk like smart caveman. Same brain, fewer tokens. + +## What it does + +Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped. + +Six intensity levels: + +| Level | What change | +|-------|-------------| +| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | +| `full` | Default. Drop articles, fragments OK, short synonyms. | +| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | +| `wenyan-lite` | Classical Chinese register, light compression. | +| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | +| `wenyan-ultra` | Extreme classical compression. | + +Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. + +## How to invoke + +``` +/caveman # full mode (default) +/caveman lite # lighter compression +/caveman ultra # extreme compression +/caveman wenyan # classical Chinese +stop caveman # back to normal prose +``` + +## Example output + +Question: "Why does my React component re-render?" + +Normal prose: +> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. + +Caveman (full): +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +Caveman (ultra): +> Inline obj prop → new ref → re-render. `useMemo`. + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 0000000..073d6bb --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,74 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman | +| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop → new ref → re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。" +- wenyan-ultra: "新參照→重繪。useMemo Wrap。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool = reuse DB conn. Skip handshake → fast under load." +- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" +- wenyan-ultra: "池reuse conn。skip handshake → fast。" + +## Auto-Clarity + +Drop caveman when: +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/.claude/skills/cavecrew b/.claude/skills/cavecrew new file mode 120000 index 0000000..9d9ca45 --- /dev/null +++ b/.claude/skills/cavecrew @@ -0,0 +1 @@ +../../.agents/skills/cavecrew \ No newline at end of file diff --git a/.claude/skills/caveman b/.claude/skills/caveman new file mode 120000 index 0000000..9016aac --- /dev/null +++ b/.claude/skills/caveman @@ -0,0 +1 @@ +../../.agents/skills/caveman \ No newline at end of file diff --git a/.claude/skills/caveman-commit b/.claude/skills/caveman-commit new file mode 120000 index 0000000..51d3fa1 --- /dev/null +++ b/.claude/skills/caveman-commit @@ -0,0 +1 @@ +../../.agents/skills/caveman-commit \ No newline at end of file diff --git a/.claude/skills/caveman-compress b/.claude/skills/caveman-compress new file mode 120000 index 0000000..b9e1fbb --- /dev/null +++ b/.claude/skills/caveman-compress @@ -0,0 +1 @@ +../../.agents/skills/caveman-compress \ No newline at end of file diff --git a/.claude/skills/caveman-help b/.claude/skills/caveman-help new file mode 120000 index 0000000..a54b411 --- /dev/null +++ b/.claude/skills/caveman-help @@ -0,0 +1 @@ +../../.agents/skills/caveman-help \ No newline at end of file diff --git a/.claude/skills/caveman-review b/.claude/skills/caveman-review new file mode 120000 index 0000000..ca28191 --- /dev/null +++ b/.claude/skills/caveman-review @@ -0,0 +1 @@ +../../.agents/skills/caveman-review \ No newline at end of file diff --git a/.claude/skills/caveman-stats b/.claude/skills/caveman-stats new file mode 120000 index 0000000..759a0ee --- /dev/null +++ b/.claude/skills/caveman-stats @@ -0,0 +1 @@ +../../.agents/skills/caveman-stats \ No newline at end of file diff --git a/.github/workflows/worker-ci.yml b/.github/workflows/worker-ci.yml new file mode 100644 index 0000000..5d7a7cc --- /dev/null +++ b/.github/workflows/worker-ci.yml @@ -0,0 +1,89 @@ +name: Worker CI + +on: + push: + branches: [ main, develop ] + paths: + - 'codehive-worker/**' + - '.github/workflows/worker-ci.yml' + pull_request: + branches: [ main, develop ] + paths: + - 'codehive-worker/**' + - '.github/workflows/worker-ci.yml' + +permissions: + checks: write + pull-requests: write + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + - name: Run unit tests + working-directory: codehive-worker + # Exclude the SpringBootTest context-load test — it requires live Docker/RabbitMQ/MinIO. + # Unit tests (ExecutionResultTest, ContainerSessionTest, LanguageExecutorFactoryTest) + # run with no external dependencies. + run: ./gradlew test --tests "com.github.codehive.worker.sandbox.*" --tests "com.github.codehive.worker.sandbox.factory.*" --info + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: worker-test-results + path: codehive-worker/build/reports/tests/test/ + retention-days: 14 + + - name: Publish test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: codehive-worker/build/test-results/test/*.xml + check_name: Worker Test Results + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build jar + working-directory: codehive-worker + run: ./gradlew build -x test + + - name: Upload build artifact + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: codehive-worker-jar + path: codehive-worker/build/libs/*.jar + retention-days: 7 diff --git a/.gitignore b/.gitignore index becb5bf..70d69f3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ logs/ npm-debug.log* yarn-debug.log* yarn-error.log* + +.crush/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..438d7fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,197 @@ +# AGENTS.md - CodeHive Root Instructions + +## Purpose +This file contains only repository-wide guidance. +Implementation specifics must live in the docs under llms/. + +## Project Overview +CodeHive is a full-stack monorepo for collaborative programming education. + +Main applications: +- codehive-backend: Spring Boot REST API (Java 21) +- codehive-frontend: React Router v7 SPA (TypeScript) +- codehive-worker: Spring Boot worker for sandboxed execution (Java 21) + +The llms folder is the source of truth for implementation-level guidance: +- llms/backend/OVERVIEW.md +- llms/frontend/OVERVIEW.md +- llms/worker/OVERVIEW.md + +Subfolder docs under each area (for example auth, service, messaging, sandbox, routes, components) contain domain-specific instructions. + +## llms Folder Structure +Use this map to load only the docs relevant to the task. + +``` +llms/ +├── backend/ +│ ├── OVERVIEW.md +│ ├── auth/ +│ ├── controller/ +│ ├── executions/ +│ ├── messaging/ +│ ├── model/ +│ ├── security/ +│ └── service/ +├── frontend/ +│ ├── OVERVIEW.md +│ ├── admin/ +│ ├── components/ +│ ├── professor/ +│ ├── routes/ +│ ├── services/ +│ └── student/ +└── worker/ + ├── OVERVIEW.md + ├── comparator/ + ├── execution/ + ├── messaging/ + └── sandbox/ +``` + +## Quick Doc Lookup +Open only the area needed for the current change. + +- Authentication, JWT, password recovery, user registration: llms/backend/auth/ +- HTTP endpoint design and controller behavior: llms/backend/controller/ +- Data model, entities, DTOs, requests, responses, exceptions: llms/backend/model/ +- Backend queue flow and contracts: llms/backend/messaging/ +- Execution request/result orchestration in backend: llms/backend/executions/ +- Backend service-layer orchestration: llms/backend/service/ +- Backend security config and filters: llms/backend/security/ + +- Frontend route mapping and route-level composition: llms/frontend/routes/ +- Frontend API clients and request/response handling: llms/frontend/services/ +- Reusable UI and guard components: llms/frontend/components/ +- Admin pages and workflows: llms/frontend/admin/ +- Professor and student areas: llms/frontend/professor/, llms/frontend/student/ (placeholders until dedicated pages exist) + +- Worker execution lifecycle and result aggregation: llms/worker/execution/ +- Worker output comparison and verdict logic: llms/worker/comparator/ +- Worker queue listener/producer behavior: llms/worker/messaging/ +- Worker sandbox executors and isolation constraints: llms/worker/sandbox/ + +## Development Environment +General prerequisites: +- Java 21 +- Node.js 18+ +- Docker and Docker Compose + +Typical local flow: +1. Start infrastructure (PostgreSQL, RabbitMQ, MinIO) with Docker Compose from codehive-backend. +2. Run backend from codehive-backend (`./gradlew bootRun`). +3. Run worker from codehive-worker when execution pipelines are needed (`./gradlew bootRun`). +4. Run frontend from codehive-frontend (`npm run dev`). + +Use project wrappers and local scripts: +- Backend and worker: ./gradlew +- Frontend: npm scripts in package.json + +## Testing +Run tests in each project independently: +- Backend: unit and integration tests via Gradle (`./gradlew test`) +- Worker: service and sandbox-related tests via Gradle (`./gradlew test`) +- Frontend: type checks and UI/app tests via npm scripts (`npm run typecheck`) + +Testing rules: +- Add or update tests for every behavior change. +- Keep tests isolated and deterministic. +- Use fixed `UUID.fromString("00000000-0000-0000-0000-000000000001")` values in tests — not `UUID.randomUUID()`. +- Prefer small unit tests plus focused integration coverage. + +## CI/CD +CI is managed with GitHub Actions under .github/workflows/. + +Expected checks for changes: +- Build passes +- Relevant tests pass +- Coverage gates (when configured) are met +- No broken formatting or lint checks + +Before opening PRs, run local checks for the affected project(s). + +## Linting And Code Style +Apply style tools per project: +- Java: follow existing formatter and conventions in backend/worker +- Frontend: follow existing TypeScript, React, and styling conventions + +Rules: +- Keep naming and package/module structure consistent with existing code. +- Avoid unrelated refactors in feature/fix PRs. +- Keep diffs focused and minimal. + +## Architecture +High-level architecture: +- Frontend consumes backend REST APIs. +- Backend handles auth, core business logic, persistence, and messaging orchestration. +- Worker consumes execution jobs, runs sandboxed code, and publishes execution results. +- Shared infrastructure includes RabbitMQ, PostgreSQL, and MinIO (object storage). + +For concrete architecture, contracts, data models, and execution flow, always consult: +- llms/backend/OVERVIEW.md +- llms/frontend/OVERVIEW.md +- llms/worker/OVERVIEW.md + +Then drill into corresponding llms subfolders for implementation details. + +## ID Convention +All entity primary keys use `java.util.UUID` (not `Long`). +- JPA entities: `@GeneratedValue(strategy = GenerationType.UUID)` +- Repositories: `JpaRepository` +- DTOs, request/response models, and queue DTOs: `UUID` fields +- Controllers: `@PathVariable UUID id` +- Frontend: entity IDs are typed as `string` + +## Queue Topology +Four durable queues — names are configurable via system properties, defaults shown: + +| Queue | Direction | Purpose | +|---|---|---| +| `codehive_queue` | backend → worker | Student code execution jobs | +| `codehive_result_queue` | worker → backend | Execution results | +| `codehive_test_generation_queue` | backend → worker | Teacher assignment creation — generate expected outputs | +| `codehive_test_generation_result_queue` | worker → backend | Output generation result | + +Payload contracts: +- `model/dto/queue/ExecutionJob` — student execution job +- `model/dto/queue/ExecutionReport` — student execution result +- `model/dto/queue/TestGenerationJob` — output generation job (contains list of `TestCaseInfo`) +- `model/dto/queue/TestGenerationResult` — output generation outcome + +Queue DTO changes are contract changes — coordinate backend and worker deployment together. + +## MinIO Object Key Conventions +All paths are produced by `utils/ObjectKeyBuilder`: + +| Method | Path pattern | +|---|---| +| `testCaseInput(assignmentId, testCaseId)` | `test-suites/assignments/{a}/tc-{t}/tc{t}.in` | +| `testCaseOutput(assignmentId, testCaseId)` | `test-suites/assignments/{a}/tc-{t}/tc{t}.out` | +| `testsPath(assignmentId)` | `test-suites/assignments/{a}/` | +| `referenceSolutionSourceCode(assignmentId, ext)` | `test-suites/assignments/{a}/reference/Main.{ext}` | +| `executionSourceCode(executionId, ext)` | `test-execution/execution-{e}/source.{ext}` | +| `executionTestCaseOutput(executionId)` | `test-execution/execution-{e}/output/` | +| `executionReport(executionId)` | `test-execution/execution-{e}/output/report.json` | +| `submissionSourceCode(assignmentId, submissionId, ext)` | `submissions/assignments/{a}/submission-{s}/Main.{ext}` | + +## Implemented Features +- Authentication: login, signup (admin), CSV bulk signup with WebSocket progress +- Password recovery: forgot/reset flow with 15-minute tokens +- Assignment creation: teacher uploads reference solution + test case inputs; worker generates expected outputs asynchronously +- Student execution: PRACTICE (inline test cases vs reference solution) and DEFINITIVE (pre-generated outputs) +- Execution status polling +- Execution report retrieval: `GET /api/execution/check/{id}/report` fetches per-test-case results from MinIO +- Docker sandbox security hardening: PID limits, capability drop, read-only rootfs, tmpfs mounts, seccomp profile, nobody user, OLE verdict for output floods + +## Not Yet Implemented (frontend) +- Professor/teacher pages for assignment management +- Student pages for submitting code and viewing results + +## Documentation Routing Rule +When working on a specific area, read docs in this order: +1. Relevant llms//OVERVIEW.md +2. Relevant llms/// documentation +3. Source code in the target module + +Do not place deep implementation playbooks in this root AGENTS.md. +Keep this file focused on repository-wide standards only. diff --git a/README.md b/README.md index 166d3ec..1b8d184 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,307 @@ -# CodeHive 🐝 - -### 📌 Status Badge - -Already included at the top of README: -```markdown [![Backend CI](https://github.com/IrminDev/CodeHive/actions/workflows/backend-ci.yml/badge.svg)](https://github.com/IrminDev/CodeHive/actions/workflows/backend-ci.yml) -``` [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Java](https://img.shields.io/badge/Java-21-orange.svg)](https://www.oracle.com/java/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5.6-brightgreen.svg)](https://spring.io/projects/spring-boot) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.x-brightgreen.svg)](https://spring.io/projects/spring-boot) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/) -> A collaborative platform for educational purposes that allows teachers to create groups with their students, and students can deliver code by editing it from the same platform. +# CodeHive -## 🚀 Features +> A collaborative platform for programming education. Teachers create assignments with automated test generation; students submit code that is evaluated in sandboxed Docker containers. -- 🔐 **Secure Authentication** - JWT-based authentication with password recovery -- 👥 **User Management** - Role-based access control (Students, Teachers, Admins) -- 🛡️ **Rate Limiting** - Protection against bot attacks and brute force -- 📧 **Email Integration** - Password reset and notifications -- 📝 **API Documentation** - Interactive Swagger/OpenAPI documentation -- ✅ **Comprehensive Testing** - 121 tests with 70%+ code coverage -- 🔄 **CI/CD** - Automated testing and deployment pipelines +## Features -## 📋 Table of Contents +- **Secure Authentication** — JWT-based login, password recovery, role-based access (Student, Teacher, Admin) +- **Bulk User Registration** — CSV upload with real-time WebSocket progress streaming +- **Assignment Management** — Teachers upload reference solutions and test case inputs; expected outputs are generated automatically by the worker +- **Sandboxed Code Execution** — Submissions run in isolated Docker containers with CPU, memory, and time limits +- **Multi-language Support** — Java, Python, C, C++ +- **Two Execution Modes** — Practice (inline test cases vs reference solution) and Definitive (pre-generated expected outputs) +- **Output Comparison** — Exact match and floating-point comparators +- **Asynchronous Pipeline** — Execution and test generation fully decoupled via RabbitMQ +- **Object Storage** — Source code, test inputs, expected outputs, and execution artifacts stored in MinIO +- **API Documentation** — Interactive Swagger/OpenAPI at `/swagger-ui.html` +- **Rate Limiting** — Per-endpoint throttling to prevent abuse +- **CI/CD** — Automated testing and coverage via GitHub Actions -- [Getting Started](#getting-started) -- [Architecture](#architecture) -- [Testing](#testing) -- [API Documentation](#api-documentation) -- [Development](#development) -- [Contributing](#contributing) +## Architecture -## 🏁 Getting Started +``` +┌─────────────┐ REST API ┌──────────────────────────────────────────────┐ +│ Frontend │ ◄────────────► │ Backend │ +│ React/TS │ │ Spring Boot · PostgreSQL · MinIO │ +└─────────────┘ │ │ + │ ┌──────────┐ ┌───────────────────────┐ │ + │ │ Auth │ │ Assignment Service │ │ + │ │ Service │ │ (upload + queue job) │ │ + │ └──────────┘ └───────────┬───────────┘ │ + │ ┌──────────────────────┐ │ │ + │ │ Execution Service │ │ │ + │ │ (load assignment, │ │ │ + │ │ queue job) │ │ │ + │ └──────────┬───────────┘ │ │ + └─────────────┼──────────────┼────────────────┘ + │ RabbitMQ │ + ┌─────────────▼──────────────▼────────────────┐ + │ Worker │ + │ Spring Boot · Docker SDK · MinIO │ + │ │ + │ ┌──────────────────┐ ┌─────────────────┐ │ + │ │ TestExecutionSvc │ │ TestGenerationSvc│ │ + │ │ (run submission) │ │ (gen outputs) │ │ + │ └──────────────────┘ └─────────────────┘ │ + └─────────────────────────────────────────────┘ +``` -### Prerequisites +### Queue Topology -- **Java 21** or later -- **PostgreSQL 15+** (for production) -- **Node.js 18+** (for frontend) -- **Gradle** (included via wrapper) +| Queue | Direction | Purpose | +|---|---|---| +| `codehive_queue` | backend → worker | Student code execution jobs | +| `codehive_result_queue` | worker → backend | Execution results | +| `codehive_test_generation_queue` | backend → worker | Generate expected test outputs | +| `codehive_test_generation_result_queue` | worker → backend | Output generation result | -### Backend Setup +### MinIO Object Layout -1. **Clone the repository** - ```bash - git clone https://github.com/IrminDev/CodeHive.git - cd CodeHive/codehive-backend - ``` +``` +test-suites/assignments/{assignmentId}/ + reference/Main.{ext} ← reference solution + tc-{testCaseId}/tc{testCaseId}.in ← test case input + tc-{testCaseId}/tc{testCaseId}.out ← expected output (worker-generated) -2. **Configure environment variables** - ```bash - cp .env.example .env - # Edit .env with your configuration - ``` +test-execution/execution-{executionId}/ + source.{ext} ← submitted code + output/tc-{n}/stdout.txt ← actual output + output/tc-{n}/stderr.txt -3. **Run the application** - ```bash - ./gradlew bootRun - ``` +submissions/assignments/{assignmentId}/submission-{id}/Main.{ext} +``` + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend API | Spring Boot 3, Java 21, Spring Security, JPA/Hibernate | +| Frontend | React Router v7, TypeScript, Vite | +| Worker | Spring Boot 3, Java 21, Docker Java SDK | +| Database | PostgreSQL | +| Message Broker | RabbitMQ | +| Object Storage | MinIO | +| Auth | JWT (stateless), BCrypt | +| Testing | JUnit 5, Mockito, AssertJ | +| Docs | SpringDoc OpenAPI / Swagger | + +## Getting Started + +### Prerequisites -4. **Access the application** - - API: http://localhost:8080 - - Swagger UI: http://localhost:8080/swagger-ui.html +- Java 21+ +- Node.js 18+ +- Docker and Docker Compose -### Frontend Setup +### 1. Start Infrastructure ```bash -cd codehive-frontend -npm install -npm run dev +cd codehive-backend +docker compose up -d ``` -Access at: http://localhost:3000 +This starts PostgreSQL, RabbitMQ, and MinIO. -## 🏗️ Architecture +### 2. Backend -### Backend Stack +```bash +cd codehive-backend +./gradlew bootRun +``` -- **Framework**: Spring Boot 3.5.6 -- **Language**: Java 21 (LTS) -- **Database**: PostgreSQL (production), H2 (testing) -- **Security**: Spring Security + JWT -- **Rate Limiting**: Bucket4j -- **Email**: JavaMailSender -- **Testing**: JUnit 5, Mockito, AssertJ -- **Documentation**: SpringDoc OpenAPI +Available at: http://localhost:8080 +Swagger UI: http://localhost:8080/swagger-ui.html -### Project Structure +### 3. Worker -``` -codehive-backend/ -├── src/main/java/com/github/codehive/ -│ ├── config/ # Configuration classes -│ ├── controller/ # REST controllers -│ ├── model/ # Entities, DTOs, requests, responses -│ ├── repository/ # JPA repositories -│ ├── service/ # Business logic -│ ├── security/ # Security configuration -│ ├── ratelimit/ # Rate limiting -│ └── utils/ # Utility classes -└── src/test/java/ # Tests (unit & integration) +```bash +cd codehive-worker +./gradlew bootRun ``` -## ✅ Testing +The worker connects to RabbitMQ and MinIO on startup and begins consuming jobs. -### Running Tests +### 4. Frontend ```bash -# All tests -./gradlew test +cd codehive-frontend +npm install +npm run dev +``` -# Unit tests only -./gradlew test --tests "*Test" --exclude-tests "*IntegrationTest" +Available at: http://localhost:3000 -# Integration tests only -./gradlew test --tests "*IntegrationTest" +## API Reference -# With coverage report -./gradlew test jacocoTestReport -``` +### Authentication -### Test Coverage +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| POST | `/api/auth/login` | — | Login with email or enrollment number | +| POST | `/api/auth/signup` | ADMIN | Create a single user account | +| POST | `/api/auth/signup/csv` | ADMIN | Bulk register users from CSV | +| GET | `/api/auth/me` | Bearer | Get current authenticated user | -- **121 total tests** - - 71 unit tests - - 50 integration tests -- **70%+ code coverage** -- **Test pyramid followed**: 70% unit, 30% integration +### Password Recovery -### Coverage Report +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| POST | `/api/recovery-password/forgot` | — | Request a password reset email | +| POST | `/api/recovery-password/reset` | — | Reset password with token | -View the detailed coverage report: -```bash -open build/reports/jacoco/test/html/index.html -``` +### Assignments -## 📚 API Documentation +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| POST | `/api/assignments` | TEACHER / ADMIN | Create assignment with reference solution and test inputs | -### Interactive Documentation +The assignment endpoint accepts `multipart/form-data` with three parts: +- `metadata` — JSON with title, description, limits, comparator, languages, sampleFlags +- `referenceSolution` — source file +- `testCaseInputs` — one or more input files (order determines test case index) -Access Swagger UI at: http://localhost:8080/swagger-ui.html +The assignment is created as inactive. The worker runs the reference solution against each input and stores the expected outputs. Once complete the assignment is automatically activated. -### Main Endpoints +### Code Execution -#### Authentication -- `POST /api/auth/login` - User login -- `POST /api/auth/signup` - User registration +| Method | Endpoint | Auth | Description | +|---|---|---|---| +| POST | `/api/execution/check` | — | Submit code for execution | +| GET | `/api/execution/check/{id}` | — | Poll execution status | -#### Password Recovery -- `POST /api/recovery-password/forgot` - Request password reset -- `POST /api/recovery-password/reset` - Reset password with token +Execution request body: -### Rate Limits +```json +{ + "code": "...", + "language": "JAVA", + "executionType": "PRACTICE", + "assignmentId": "uuid", + "requesterId": "uuid", + "testCases": ["input1", "input2"] +} +``` -| Endpoint | Limit | Duration | -|----------|-------|----------| -| Login | 5 requests | 60 seconds | -| Signup | 3 requests | 5 minutes | -| Forgot Password | 3 requests | 5 minutes | -| Reset Password | 5 requests | 5 minutes | +`PRACTICE` — inline test cases are compared against the reference solution output. +`DEFINITIVE` — submission is compared against pre-generated expected outputs from MinIO. -## 🛠️ Development +### Rate Limits -### Code Style +| Endpoint | Limit | Window | +|---|---|---| +| POST `/api/auth/login` | 5 requests | 60 s | +| POST `/api/auth/signup` | 3 requests | 5 min | +| POST `/api/recovery-password/forgot` | 3 requests | 5 min | +| POST `/api/recovery-password/reset` | 5 requests | 5 min | +| POST `/api/execution/check` | 10 requests | 60 s | -- Follow Java naming conventions -- Use meaningful variable/method names -- Add JavaDoc for public APIs -- Keep methods focused and small +## Execution Verdict Reference -### Git Workflow +| Status | Meaning | +|---|---| +| `PENDING` | Queued, not yet processed | +| `AC` | Accepted — all test cases passed | +| `WA` | Wrong Answer | +| `CE` | Compilation Error | +| `RTE` | Runtime Error | +| `TLE` | Time Limit Exceeded | +| `MLE` | Memory Limit Exceeded | -1. Create a feature branch: `git checkout -b feature/my-feature` -2. Make your changes and commit: `git commit -am 'Add new feature'` -3. Push to the branch: `git push origin feature/my-feature` -4. Create a Pull Request +Overall verdict priority when tests fail: `CE > TLE > MLE > RTE > WA`. -### CI/CD Pipeline +## Supported Languages -Every push and PR triggers: -- ✅ Automated testing (unit & integration) -- 📊 Code coverage analysis -- 🏗️ Application build -- 📝 Test results published to PR +| Language | Compile command | Run command | +|---|---|---| +| Java | `javac Main.java` | `java Main` | +| Python | — | `python main.py` | +| C | `gcc -o program main.c -lm` | `./program` | +| C++ | `g++ -o program main.cpp -std=c++17 -lm` | `./program` | -See [.github/workflows/README.md](.github/workflows/README.md) for details. +## Testing -## 🤝 Contributing +```bash +# Backend unit and integration tests +cd codehive-backend +./gradlew test -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. +# With coverage report +./gradlew test jacocoTestReport +open build/reports/jacoco/test/html/index.html -### Quick Start for Contributors +# Worker tests +cd codehive-worker +./gradlew test -1. Fork the repository -2. Create your feature branch -3. Write tests for your changes -4. Ensure all tests pass: `./gradlew test` -5. Commit your changes -6. Push to your fork -7. Create a Pull Request +# Frontend type check +cd codehive-frontend +npm run typecheck +``` -## 📝 License +## Project Structure -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +``` +CodeHive/ +├── codehive-backend/ ← Spring Boot REST API +│ ├── src/main/java/com/github/codehive/ +│ │ ├── config/ ← RabbitMQ, MinIO, Security, WebSocket, Async +│ │ ├── controller/ ← Auth, RecoveryPassword, Assignment, CheckExecution +│ │ ├── messaging/ ← Producers and listeners for both queue pairs +│ │ ├── model/ ← Entities, DTOs, queue DTOs, requests, responses +│ │ ├── repository/ ← JPA repositories (UUID primary keys) +│ │ ├── security/ ← JWT filter, UserDetailsService +│ │ ├── service/ ← Auth, Assignment, Execution, ObjectStorage, Mail +│ │ ├── ratelimit/ ← @RateLimit annotation and aspect +│ │ ├── websocket/ ← CSV progress handler +│ │ └── utils/ ← ObjectKeyBuilder, FileExtensionUtil, JwtUtil +│ └── src/test/ ← Unit and integration tests +│ +├── codehive-worker/ ← Sandboxed execution worker +│ └── src/main/java/com/github/codehive/worker/ +│ ├── config/ ← Docker client, MinIO, RabbitMQ +│ ├── messaging/ ← Listeners (execution + generation) and producers +│ ├── model/ ← DTOs and enums +│ ├── sandbox/ ← LanguageExecutor interface and implementations +│ └── service/ ← TestExecutionService, TestGenerationService +│ +├── codehive-frontend/ ← React Router v7 SPA +│ └── app/ +│ ├── components/ ← Reusable UI and ProtectedRoute guard +│ ├── context/ ← Auth and theme providers +│ ├── pages/ ← Admin, login, recovery pages +│ ├── routes/ ← Route entry files +│ ├── services/ ← AuthService, RecoveryPasswordService +│ └── types/ ← TypeScript contracts +│ +└── llms/ ← Implementation documentation for AI agents + ├── backend/ + ├── frontend/ + └── worker/ +``` + +## Contributing -## 👥 Authors +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Write tests for your changes +4. Ensure all tests pass +5. Open a Pull Request against `main` -- **Irmin** - *Initial work* - [IrminDev](https://github.com/IrminDev) +## License -## 🙏 Acknowledgments +MIT — see [LICENSE](LICENSE) for details. -- Spring Boot team for the excellent framework -- All contributors who have helped this project +## Authors -## 📞 Support +- **Irmin Hernandez Jimenez** — [IrminDev](https://github.com/IrminDev) +- **Johann Daniel Trejo Flores** — [JohannTF](https://github.com/JohannTF) +- **Rodolfo Aparicio Lopez** — [rodolfo-rgb ](https://github.com/rodolfo-rgb) -- 📧 Email: irmin@codehive.com -- 🐛 Issues: [GitHub Issues](https://github.com/IrminDev/CodeHive/issues) -- 💬 Discussions: [GitHub Discussions](https://github.com/IrminDev/CodeHive/discussions) --- - -Made with ❤️ for education diff --git a/codehive-backend/build.gradle.kts b/codehive-backend/build.gradle.kts index 5dbdfe5..e3de5b8 100644 --- a/codehive-backend/build.gradle.kts +++ b/codehive-backend/build.gradle.kts @@ -54,6 +54,12 @@ dependencies { testImplementation("org.assertj:assertj-core") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("com.h2database:h2") + + // Minio + implementation("io.minio:minio:8.6.0") + + // RabbitMQ + implementation("org.springframework.boot:spring-boot-starter-amqp") } tasks.withType { diff --git a/codehive-backend/docker-compose.yaml b/codehive-backend/docker-compose.yaml index d730bad..4ef8113 100644 --- a/codehive-backend/docker-compose.yaml +++ b/codehive-backend/docker-compose.yaml @@ -1,15 +1,54 @@ services: postgres: - image: 'postgres:18.1-alpine' - container_name: 'db' + image: "postgres:18.1-alpine" + container_name: "db" environment: - - 'POSTGRES_DB=${DATABASE_NAME}' - - 'POSTGRES_PASSWORD=${DATABASE_PASSWORD}' - - 'POSTGRES_USER=${DATABASE_USERNAME}' + - "POSTGRES_DB=${DATABASE_NAME}" + - "POSTGRES_PASSWORD=${DATABASE_PASSWORD}" + - "POSTGRES_USER=${DATABASE_USERNAME}" ports: - - '5432:5432' + - "5432:5432" volumes: - - 'db_data:/var/lib/postgresql/data' + - "db_data:/var/lib/postgresql/data" + + rabbitmq: + image: "rabbitmq:4.2.2-management-alpine" + container_name: "rabbitmq" + ports: + - "5672:5672" + - "15672:15672" + + minio: + image: "minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1" + container_name: "minio" + command: 'server /data --console-address ":9001"' + ports: + - "9000:9000" + - "9001:9001" + environment: + - "MINIO_ROOT_USER=${MINIO_ACCESS_KEY}" + - "MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY}" + volumes: + - "minio_data:/data" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + minio-init: + image: "minio/mc:latest" + container_name: "minio-init" + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY}; + mc mb --ignore-existing local/${MINIO_BUCKET_NAME}; + exit 0; + " volumes: db_data: + minio_data: diff --git a/codehive-backend/scripts/fake_users.csv b/codehive-backend/scripts/fake_users.csv new file mode 100644 index 0000000..8296d55 --- /dev/null +++ b/codehive-backend/scripts/fake_users.csv @@ -0,0 +1,10 @@ +TEACHER,Karla,Torres,Chavez,2023630627,karla.torres@ittepic.edu.mx +TEACHER,Elena,Torres,Chavez,1998630022,elena.torres@yahoo.com +STUDENT,Luis,Gonzalez,Ramirez,1995630277,luis.gonzalez@ittepic.edu.mx +TEACHER,Sergio,Morales,Flores,1999630615,sergio.morales@gmail.com +STUDENT,Jorge,Cruz,Jimenez,1998630149,jorge.cruz@ittepic.edu.mx +TEACHER,Eduardo,Aguilar,Castillo,2004630352,eduardo.aguilar@hotmail.com +STUDENT,Andres,Castillo,Gutierrez,2011630950,andres.castillo@hotmail.com +STUDENT,Ricardo,Martinez,Gonzalez,2001630813,ricardo.martinez@hotmail.com +STUDENT,Alejandra,Gomez,Silva,2011630694,alejandra.gomez@ittepic.edu.mx +STUDENT,Elena,Silva,Ramirez,1998630419,elena.silva@outlook.com diff --git a/codehive-backend/scripts/generate_fake_users.py b/codehive-backend/scripts/generate_fake_users.py new file mode 100644 index 0000000..cebe352 --- /dev/null +++ b/codehive-backend/scripts/generate_fake_users.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Generates a CSV file with fake user data for CodeHive bulk registration. +CSV format: role, name, father_last_name, mother_last_name, enrollment_number, email +""" + +import csv +import random +import string +import sys +from datetime import datetime + +FIRST_NAMES = [ + "Carlos", "Maria", "Jose", "Ana", "Luis", "Laura", "Miguel", "Sofia", + "Jorge", "Elena", "Ricardo", "Valeria", "Fernando", "Gabriela", "Andres", + "Patricia", "Diego", "Monica", "Roberto", "Alejandra", "Eduardo", "Claudia", + "Sergio", "Natalia", "Pablo", "Daniela", "Ivan", "Karla", "Oscar", "Veronica", +] + +LAST_NAMES = [ + "Garcia", "Martinez", "Lopez", "Hernandez", "Gonzalez", "Perez", "Rodriguez", + "Sanchez", "Ramirez", "Torres", "Flores", "Rivera", "Gomez", "Diaz", "Cruz", + "Morales", "Reyes", "Gutierrez", "Ortiz", "Chavez", "Romero", "Jimenez", + "Vargas", "Mendoza", "Ruiz", "Aguilar", "Medina", "Castillo", "Herrera", "Silva", +] + +EMAIL_DOMAINS = ["gmail.com", "hotmail.com", "outlook.com", "yahoo.com", "ittepic.edu.mx"] + +CURRENT_YEAR = datetime.now().year + + +def random_name(): + return random.choice(FIRST_NAMES) + + +def random_last_name(): + return random.choice(LAST_NAMES) + + +def generate_enrollment_number(used: set) -> str: + while True: + year = random.randint(1994, CURRENT_YEAR) + suffix = random.randint(0, 999) + number = f"{year}630{suffix:03d}" + if number not in used: + used.add(number) + return number + + +def generate_email(name: str, father_last: str, used: set) -> str: + base = f"{name.lower()}.{father_last.lower()}" + domain = random.choice(EMAIL_DOMAINS) + candidate = f"{base}@{domain}" + if candidate not in used: + used.add(candidate) + return candidate + # Append random digits to avoid collision + while True: + candidate = f"{base}{random.randint(1, 9999)}@{domain}" + if candidate not in used: + used.add(candidate) + return candidate + + +def generate_users(n_admins: int, n_teachers: int, n_students: int) -> list[dict]: + used_enrollments: set = set() + used_emails: set = set() + users = [] + + roles = ( + [("ADMIN", n_admins), ("TEACHER", n_teachers), ("STUDENT", n_students)] + ) + + for role, count in roles: + for _ in range(count): + name = random_name() + father_last = random_last_name() + mother_last = random_last_name() + enrollment = generate_enrollment_number(used_enrollments) + email = generate_email(name, father_last, used_emails) + users.append({ + "role": role, + "name": name, + "father_last_name": father_last, + "mother_last_name": mother_last, + "enrollment_number": enrollment, + "email": email, + }) + + random.shuffle(users) + return users + + +def main(): + print("CodeHive fake user CSV generator") + print("-" * 35) + + try: + n_admins = int(input("Number of admins (n1): ").strip()) + n_teachers = int(input("Number of teachers (n2): ").strip()) + n_students = int(input("Number of students (n3): ").strip()) + except ValueError: + print("Error: all inputs must be integers.", file=sys.stderr) + sys.exit(1) + + if any(v < 0 for v in (n_admins, n_teachers, n_students)): + print("Error: counts must be non-negative.", file=sys.stderr) + sys.exit(1) + + total = n_admins + n_teachers + n_students + if total == 0: + print("Nothing to generate.") + sys.exit(0) + + output_file = "fake_users.csv" + users = generate_users(n_admins, n_teachers, n_students) + + with open(output_file, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + for u in users: + writer.writerow([ + u["role"], + u["name"], + u["father_last_name"], + u["mother_last_name"], + u["enrollment_number"], + u["email"], + ]) + + print(f"\nGenerated {total} users ({n_admins} admins, {n_teachers} teachers, {n_students} students)") + print(f"Saved to: {output_file}") + + +if __name__ == "__main__": + main() diff --git a/codehive-backend/src/main/java/com/github/codehive/config/AdminInitializer.java b/codehive-backend/src/main/java/com/github/codehive/config/AdminInitializer.java index c45c8aa..b355551 100644 --- a/codehive-backend/src/main/java/com/github/codehive/config/AdminInitializer.java +++ b/codehive-backend/src/main/java/com/github/codehive/config/AdminInitializer.java @@ -1,7 +1,5 @@ package com.github.codehive.config; -import java.util.List; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -25,7 +23,7 @@ public class AdminInitializer implements CommandLineRunner { @Value("${app.admin.email:admin@codehive.com}") private String adminEmail; - @Value("${app.admin.password}") + @Value("${app.admin.password:Admin@12345}") private String adminPassword; @Value("${app.admin.name:Super}") @@ -49,22 +47,13 @@ public void run(String... args) { return; } - User admin = new User(); - admin.setEmail(adminEmail); - admin.setPassword(passwordEncoder.encode(adminPassword)); - admin.setName(adminName); - admin.setLastName(adminLastName); - admin.setEnrollmentNumber(adminEnrollmentNumber); - admin.setRole(Role.ADMIN); - admin.setIsActive(true); - admin.setTemporaryPassword(false); - admin.setScopes(List.of( - Scope.SUPER_ADMIN, - Scope.MANAGE_USERS, - Scope.MANAGE_GROUPS, - Scope.CHECK_ANALYTICS, - Scope.CREATE_GROUP - )); + User admin = new User(adminName, adminLastName, adminEnrollmentNumber, adminEmail, + passwordEncoder.encode(adminPassword), Role.ADMIN); + admin.addScope(Scope.SUPER_ADMIN); + admin.addScope(Scope.MANAGE_USERS); + admin.addScope(Scope.MANAGE_GROUPS); + admin.addScope(Scope.CHECK_ANALYTICS); + admin.addScope(Scope.CREATE_GROUP); userRepository.save(admin); logger.info("Default super admin created: {}", adminEmail); diff --git a/codehive-backend/src/main/java/com/github/codehive/config/MinioConfig.java b/codehive-backend/src/main/java/com/github/codehive/config/MinioConfig.java new file mode 100644 index 0000000..c4b90f8 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/config/MinioConfig.java @@ -0,0 +1,29 @@ +package com.github.codehive.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.minio.MinioClient; + +@Configuration +public class MinioConfig { + @Value("${minio.url}") + private String minioUrl; + + @Value("${minio.accessKey}") + private String minioAccessKey; + + @Value("${minio.secretKey}") + private String minioSecretKey; + + @Bean + MinioClient minioClient() { + return MinioClient.builder() + .endpoint(minioUrl) + .credentials( + minioAccessKey, + minioSecretKey) + .build(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/config/RabbitConfig.java b/codehive-backend/src/main/java/com/github/codehive/config/RabbitConfig.java new file mode 100644 index 0000000..694fda4 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/config/RabbitConfig.java @@ -0,0 +1,40 @@ +package com.github.codehive.config; + +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitConfig { + public static final String QUEUE_NAME = System.getProperty("rabbitmq.queue", "codehive_queue"); + public static final String RESULT_QUEUE_NAME = System.getProperty("rabbitmq.result.queue", "codehive_result_queue"); + public static final String TEST_GENERATION_QUEUE_NAME = System.getProperty("rabbitmq.test-generation.queue", "codehive_test_generation_queue"); + public static final String TEST_GENERATION_RESULT_QUEUE_NAME = System.getProperty("rabbitmq.test-generation.result.queue", "codehive_test_generation_result_queue"); + + @Bean + Queue executionQueue() { + return new Queue(QUEUE_NAME, true); + } + + @Bean + Queue resultQueue() { + return new Queue(RESULT_QUEUE_NAME, true); + } + + @Bean + Queue testGenerationQueue() { + return new Queue(TEST_GENERATION_QUEUE_NAME, true); + } + + @Bean + Queue testGenerationResultQueue() { + return new Queue(TEST_GENERATION_RESULT_QUEUE_NAME, true); + } + + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/config/SecurityConfig.java b/codehive-backend/src/main/java/com/github/codehive/config/SecurityConfig.java index 6d8dd15..eea8458 100644 --- a/codehive-backend/src/main/java/com/github/codehive/config/SecurityConfig.java +++ b/codehive-backend/src/main/java/com/github/codehive/config/SecurityConfig.java @@ -55,6 +55,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/ws/**").permitAll() // Swagger/OpenAPI documentation .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + // Assignment upload restricted to teachers + .requestMatchers(HttpMethod.POST, "/api/assignments").hasAnyAuthority("TEACHER", "ADMIN") // All other requests require authentication .anyRequest().authenticated() ) diff --git a/codehive-backend/src/main/java/com/github/codehive/controller/AssignmentController.java b/codehive-backend/src/main/java/com/github/codehive/controller/AssignmentController.java new file mode 100644 index 0000000..1b9421c --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/controller/AssignmentController.java @@ -0,0 +1,119 @@ +package com.github.codehive.controller; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.github.codehive.model.dto.AssignmentDTO; +import com.github.codehive.model.request.assignment.CreateAssignmentRequest; +import com.github.codehive.model.response.ErrorResponse; +import com.github.codehive.model.response.PageResponse; +import com.github.codehive.model.response.SuccessResponse; +import com.github.codehive.service.AssignmentService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/assignments") +@Tag(name = "Assignments", description = "Assignment management APIs") +public class AssignmentController { + private static final Logger logger = LoggerFactory.getLogger(AssignmentController.class); + + private final AssignmentService assignmentService; + + public AssignmentController(AssignmentService assignmentService) { + this.assignmentService = assignmentService; + } + + @Operation(summary = "List assignments (paginated)", + description = "Returns a page of assignments ordered by creation date descending.") + @ApiResponse(responseCode = "200", description = "Assignments retrieved successfully", + content = @Content(schema = @Schema(implementation = SuccessResponse.class))) + @GetMapping + public ResponseEntity>> listAssignments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page result = assignmentService.listAssignments(page, size); + return ResponseEntity.ok(new SuccessResponse<>("Assignments retrieved successfully.", new PageResponse<>(result))); + } + + @Operation(summary = "Get assignment by ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Assignment retrieved successfully", + content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "404", description = "Assignment not found", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/{id}") + public ResponseEntity> getAssignment(@PathVariable UUID id) { + return ResponseEntity.ok(new SuccessResponse<>("Assignment retrieved successfully.", assignmentService.getAssignmentById(id))); + } + + @Operation( + summary = "Create a new assignment", + description = "Teacher uploads assignment metadata, a reference solution, and test case input files. " + + "The assignment is created as inactive. The worker runs the reference solution against each " + + "input to generate expected outputs, then activates the assignment." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "202", + description = "Assignment created and test generation queued", + content = @Content(schema = @Schema(implementation = SuccessResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error or missing files", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden — only TEACHER or ADMIN role allowed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PreAuthorize("hasAnyAuthority('TEACHER', 'ADMIN')") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> createAssignment( + @Valid @RequestPart("metadata") CreateAssignmentRequest metadata, + @RequestPart("referenceSolution") MultipartFile referenceSolution, + @RequestPart("testCaseInputs") List testCaseInputs) { + + logger.info("POST /api/assignments - title={}, language={}, testCases={}", + metadata.getTitle(), metadata.getReferenceLanguage(), testCaseInputs.size()); + + if (testCaseInputs.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + AssignmentDTO created = assignmentService.createAssignment(metadata, referenceSolution, testCaseInputs); + + logger.info("Assignment queued for test generation: id={}", created.getId()); + + return ResponseEntity + .status(HttpStatus.ACCEPTED) + .body(new SuccessResponse<>("Assignment created. Test output generation in progress.", created)); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/controller/AuthController.java b/codehive-backend/src/main/java/com/github/codehive/controller/AuthController.java index 899780e..028f7b9 100644 --- a/codehive-backend/src/main/java/com/github/codehive/controller/AuthController.java +++ b/codehive-backend/src/main/java/com/github/codehive/controller/AuthController.java @@ -7,11 +7,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -57,12 +57,8 @@ public AuthController(AuthService authService, CsvRegistrationService csvRegistr content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/me") - public ResponseEntity> me(@RequestHeader(value = "Authorization", required = false) String authHeader) { - if (authHeader == null || !authHeader.startsWith("Bearer ") || authHeader.length() <= 7) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - String token = authHeader.substring(7); - UserDTO userDTO = authService.getUserByToken(token); + public ResponseEntity> me(Authentication authentication) { + UserDTO userDTO = authService.getUserByEmail(authentication.getName()); SuccessResponse response = new SuccessResponse<>("User info retrieved", userDTO); return ResponseEntity.ok(response); } @@ -139,13 +135,9 @@ public ResponseEntity>> signupFromCsv( }) @PutMapping("/me/password") public ResponseEntity> updatePassword( - @RequestHeader(value = "Authorization") String authHeader, + Authentication authentication, @Valid @RequestBody UpdatePasswordRequest request) { - if (authHeader == null || !authHeader.startsWith("Bearer ") || authHeader.length() <= 7) { - throw new ValidationException("Invalid Authorization header format"); - } - String token = authHeader.substring(7); - UserDTO userDTO = authService.getUserByToken(token); + UserDTO userDTO = authService.getUserByEmail(authentication.getName()); authService.updatePassword(userDTO.getId(), request); return ResponseEntity.ok(new SuccessResponse<>("Password updated successfully", null)); } diff --git a/codehive-backend/src/main/java/com/github/codehive/controller/CheckExecutionController.java b/codehive-backend/src/main/java/com/github/codehive/controller/CheckExecutionController.java new file mode 100644 index 0000000..9426065 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/controller/CheckExecutionController.java @@ -0,0 +1,176 @@ +package com.github.codehive.controller; + +import com.github.codehive.model.dto.ExecutionDTO; +import com.github.codehive.model.dto.queue.ExecutionReport; +import com.github.codehive.model.request.execution.ExecutionRequest; +import com.github.codehive.model.response.ErrorResponse; +import com.github.codehive.model.response.SuccessResponse; +import com.github.codehive.ratelimit.RateLimit; +import com.github.codehive.service.ExecutionRequestService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller for code execution endpoints. + * Allows submitting code for execution and checking execution status. + */ +@RestController +@RequestMapping("/api/execution") +@Tag( + name = "Code Execution", + description = "Code execution and result checking APIs" +) +public class CheckExecutionController { + + private static final Logger logger = LoggerFactory.getLogger( + CheckExecutionController.class + ); + + private final ExecutionRequestService executionRequestService; + + public CheckExecutionController( + ExecutionRequestService executionRequestService + ) { + this.executionRequestService = executionRequestService; + } + + @Operation( + summary = "Submit code for execution", + description = "Submits code to be executed in a sandboxed environment. Returns execution ID for status polling." + ) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "202", + description = "Execution request accepted", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Validation error", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "429", + description = "Too many requests", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + } + ) + @RateLimit( + limit = 10, + duration = 60, + message = "Too many execution requests. Please try again in 1 minute." + ) + @PostMapping("/check") + public ResponseEntity> submitExecution( + @Valid @RequestBody ExecutionRequest request + ) { + ExecutionDTO execution = executionRequestService.requestExecution( + request + ); + + SuccessResponse response = new SuccessResponse<>( + "Execution request submitted successfully", + execution + ); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @Operation( + summary = "Get execution status", + description = "Retrieves the current status and results of a code execution by its ID." + ) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Execution found", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Execution not found", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + } + ) + @GetMapping("/check/{id}") + public ResponseEntity> getExecution( + @Parameter( + description = "Execution ID", + required = true + ) @PathVariable UUID id + ) { + ExecutionDTO execution = executionRequestService.getExecutionById(id); + SuccessResponse response = new SuccessResponse<>( + "Execution retrieved successfully", + execution + ); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get execution report", + description = "Retrieves the full execution report from object storage, including per-test-case results, timing, memory, and feedback." + ) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Report retrieved successfully", + content = @Content( + schema = @Schema(implementation = SuccessResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "Execution not found or report not available yet", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + } + ) + @GetMapping("/check/{id}/report") + public ResponseEntity> getExecutionReport( + @Parameter( + description = "Execution ID", + required = true + ) @PathVariable UUID id + ) { + ExecutionReport report = executionRequestService.getExecutionReport(id); + SuccessResponse response = new SuccessResponse<>( + "Execution report retrieved successfully", + report + ); + return ResponseEntity.ok(response); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/messaging/listener/ExecutionResultListener.java b/codehive-backend/src/main/java/com/github/codehive/messaging/listener/ExecutionResultListener.java new file mode 100644 index 0000000..d62d281 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/messaging/listener/ExecutionResultListener.java @@ -0,0 +1,37 @@ +package com.github.codehive.messaging.listener; + +import com.github.codehive.model.dto.queue.ExecutionReport; +import com.github.codehive.service.ExecutionResultService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Component +public class ExecutionResultListener { + private static final Logger logger = LoggerFactory.getLogger(ExecutionResultListener.class); + + private final ExecutionResultService executionResultService; + + public ExecutionResultListener(ExecutionResultService executionResultService) { + this.executionResultService = executionResultService; + } + + @RabbitListener(queues = "${rabbitmq.result.queue:codehive_result_queue}") + public void handleExecutionResult(ExecutionReport report) { + logger.info("[WORKFLOW] RABBITMQ RECEIVE: Received execution result from worker - executionId={}, overallStatus={}", + report.getExecutionId(), report.getOverallStatus()); + logger.info("[WORKFLOW] RABBITMQ RECEIVE: Result details - passed={}/{}, maxTimeMs={}, maxMemoryMb={}", + report.getPassedTests(), report.getTotalTests(), + report.getMaxExecutionTimeMs(), report.getMaxMemoryUsedMb()); + + try { + executionResultService.processExecutionResult(report); + logger.info("[WORKFLOW] RABBITMQ RECEIVE: Successfully processed result - executionId={}", + report.getExecutionId()); + } catch (Exception e) { + logger.error("[WORKFLOW] RABBITMQ RECEIVE FAILED: Could not process result - executionId={}, error={}", + report.getExecutionId(), e.getMessage(), e); + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/messaging/listener/TestGenerationResultListener.java b/codehive-backend/src/main/java/com/github/codehive/messaging/listener/TestGenerationResultListener.java new file mode 100644 index 0000000..b666773 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/messaging/listener/TestGenerationResultListener.java @@ -0,0 +1,44 @@ +package com.github.codehive.messaging.listener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.github.codehive.model.dto.queue.TestGenerationResult; +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.repository.AssignmentRepository; + +@Component +public class TestGenerationResultListener { + private static final Logger logger = LoggerFactory.getLogger(TestGenerationResultListener.class); + + private final AssignmentRepository assignmentRepository; + + public TestGenerationResultListener(AssignmentRepository assignmentRepository) { + this.assignmentRepository = assignmentRepository; + } + + @RabbitListener(queues = "${rabbitmq.test-generation.result.queue:codehive_test_generation_result_queue}") + public void handleTestGenerationResult(TestGenerationResult result) { + logger.info("[WORKFLOW] RABBITMQ RECEIVE: Test generation result - assignmentId={}, success={}, generated={}", + result.getAssignmentId(), result.isSuccess(), result.getGeneratedCount()); + + Assignment assignment = assignmentRepository.findById(result.getAssignmentId()).orElse(null); + if (assignment == null) { + logger.warn("[WORKFLOW] Assignment not found for test generation result - assignmentId={}", + result.getAssignmentId()); + return; + } + + if (result.isSuccess()) { + assignment.setIsActive(true); + assignmentRepository.save(assignment); + logger.info("[WORKFLOW] Assignment activated after test generation - assignmentId={}, generatedOutputs={}", + assignment.getId(), result.getGeneratedCount()); + } else { + logger.error("[WORKFLOW] Test generation failed for assignmentId={}, error={}", + result.getAssignmentId(), result.getErrorMessage()); + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/messaging/producer/ExecutionRequestProducer.java b/codehive-backend/src/main/java/com/github/codehive/messaging/producer/ExecutionRequestProducer.java new file mode 100644 index 0000000..12a3cd0 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/messaging/producer/ExecutionRequestProducer.java @@ -0,0 +1,37 @@ +package com.github.codehive.messaging.producer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import com.github.codehive.config.RabbitConfig; +import com.github.codehive.model.dto.queue.ExecutionJob; + +@Service +public class ExecutionRequestProducer { + private static final Logger logger = LoggerFactory.getLogger(ExecutionRequestProducer.class); + + private final RabbitTemplate rabbitTemplate; + + public ExecutionRequestProducer(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + public void sendExecutionRequest(ExecutionJob job) { + logger.info("[WORKFLOW] RABBITMQ SEND: Preparing to send execution request - executionId={}, language={}, executionType={}", + job.getId(), job.getLanguage(), job.getExecutionType()); + logger.info("[WORKFLOW] RABBITMQ SEND: Job details - sourceKey={}, timeLimitMs={}, memoryLimitMb={}, numTests={}", + job.getSource(), job.getTimeLimitMs(), job.getMemoryLimitMb(), job.getNumTests()); + + try { + rabbitTemplate.convertAndSend(RabbitConfig.QUEUE_NAME, job); + logger.info("[WORKFLOW] RABBITMQ SEND: Successfully sent to queue '{}' - executionId={}", + RabbitConfig.QUEUE_NAME, job.getId()); + } catch (Exception e) { + logger.error("[WORKFLOW] RABBITMQ SEND FAILED: Could not send to queue '{}' - executionId={}, error={}", + RabbitConfig.QUEUE_NAME, job.getId(), e.getMessage(), e); + throw e; + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/messaging/producer/TestGenerationRequestProducer.java b/codehive-backend/src/main/java/com/github/codehive/messaging/producer/TestGenerationRequestProducer.java new file mode 100644 index 0000000..a49e5b5 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/messaging/producer/TestGenerationRequestProducer.java @@ -0,0 +1,34 @@ +package com.github.codehive.messaging.producer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import com.github.codehive.config.RabbitConfig; +import com.github.codehive.model.dto.queue.TestGenerationJob; + +@Service +public class TestGenerationRequestProducer { + private static final Logger logger = LoggerFactory.getLogger(TestGenerationRequestProducer.class); + + private final RabbitTemplate rabbitTemplate; + + public TestGenerationRequestProducer(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + public void sendTestGenerationRequest(TestGenerationJob job) { + logger.info("[WORKFLOW] RABBITMQ SEND: Sending test generation job - assignmentId={}, testCases={}, language={}", + job.getAssignmentId(), job.getTestCases().size(), job.getReferenceLanguage()); + try { + rabbitTemplate.convertAndSend(RabbitConfig.TEST_GENERATION_QUEUE_NAME, job); + logger.info("[WORKFLOW] RABBITMQ SEND: Test generation job queued successfully - assignmentId={}", + job.getAssignmentId()); + } catch (Exception e) { + logger.error("[WORKFLOW] RABBITMQ SEND FAILED: Could not queue test generation job - assignmentId={}, error={}", + job.getAssignmentId(), e.getMessage(), e); + throw e; + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/AssignmentDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/AssignmentDTO.java new file mode 100644 index 0000000..12bce00 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/AssignmentDTO.java @@ -0,0 +1,148 @@ +package com.github.codehive.model.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.Language; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AssignmentDTO { + private UUID id; + private String title; + private String description; + private List constraints; + private List hints; + private List tags; + private Long timeLimitMs; + private Long memoryLimitMb; + private ComparatorType comparatorType; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime dueDate; + private List allowedLanguages; + private Boolean isActive; + private List sampleTestCases; + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getConstraints() { + return constraints; + } + + public void setConstraints(List constraints) { + this.constraints = constraints; + } + + public List getHints() { + return hints; + } + + public void setHints(List hints) { + this.hints = hints; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public Long getTimeLimitMs() { + return timeLimitMs; + } + + public void setTimeLimitMs(Long timeLimitMs) { + this.timeLimitMs = timeLimitMs; + } + + public Long getMemoryLimitMb() { + return memoryLimitMb; + } + + public void setMemoryLimitMb(Long memoryLimitMb) { + this.memoryLimitMb = memoryLimitMb; + } + + public ComparatorType getComparatorType() { + return comparatorType; + } + + public void setComparatorType(ComparatorType comparatorType) { + this.comparatorType = comparatorType; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDueDate() { + return dueDate; + } + + public void setDueDate(LocalDateTime dueDate) { + this.dueDate = dueDate; + } + + public List getAllowedLanguages() { + return allowedLanguages; + } + + public void setAllowedLanguages(List allowedLanguages) { + this.allowedLanguages = allowedLanguages; + } + + public List getSampleTestCases() { + return sampleTestCases; + } + + public void setSampleTestCases(List sampleTestCases) { + this.sampleTestCases = sampleTestCases; + } +} \ No newline at end of file diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/ExecutionDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/ExecutionDTO.java new file mode 100644 index 0000000..85d2cff --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/ExecutionDTO.java @@ -0,0 +1,93 @@ +package com.github.codehive.model.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.codehive.model.enums.ExecutionStatus; +import com.github.codehive.model.enums.ExecutionType; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExecutionDTO { + private UUID id; + private UUID submissionId; + private UUID userId; + private ExecutionType executionType; + private ExecutionStatus status; + private Long timeMs; + private Long memoryMb; + private Boolean isOutdated; + private LocalDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getSubmissionId() { + return submissionId; + } + + public void setSubmissionId(UUID submissionId) { + this.submissionId = submissionId; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public ExecutionType getExecutionType() { + return executionType; + } + + public void setExecutionType(ExecutionType executionType) { + this.executionType = executionType; + } + + public ExecutionStatus getStatus() { + return status; + } + + public void setStatus(ExecutionStatus status) { + this.status = status; + } + + public Long getTimeMs() { + return timeMs; + } + + public void setTimeMs(Long timeMs) { + this.timeMs = timeMs; + } + + public Long getMemoryMb() { + return memoryMb; + } + + public void setMemoryMb(Long memoryMb) { + this.memoryMb = memoryMb; + } + + public Boolean getIsOutdated() { + return isOutdated; + } + + public void setIsOutdated(Boolean isOutdated) { + this.isOutdated = isOutdated; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/ReferenceSolutionDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/ReferenceSolutionDTO.java new file mode 100644 index 0000000..dd1ab5a --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/ReferenceSolutionDTO.java @@ -0,0 +1,37 @@ +package com.github.codehive.model.dto; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.codehive.model.enums.Language; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReferenceSolutionDTO { + private UUID id; + private UUID assignmentId; + private Language language; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/SampleTestCaseDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/SampleTestCaseDTO.java new file mode 100644 index 0000000..815a561 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/SampleTestCaseDTO.java @@ -0,0 +1,21 @@ +package com.github.codehive.model.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SampleTestCaseDTO { + private int order; + private String input; + + public SampleTestCaseDTO() {} + + public SampleTestCaseDTO(int order, String input) { + this.order = order; + this.input = input; + } + + public int getOrder() { return order; } + public void setOrder(int order) { this.order = order; } + public String getInput() { return input; } + public void setInput(String input) { this.input = input; } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/SubmissionDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/SubmissionDTO.java new file mode 100644 index 0000000..f3df4ff --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/SubmissionDTO.java @@ -0,0 +1,47 @@ +package com.github.codehive.model.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.codehive.model.enums.Language; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SubmissionDTO { + private UUID id; + private UUID assignmentId; + private Language language; + private LocalDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/TestCaseDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/TestCaseDTO.java new file mode 100644 index 0000000..47ee5ba --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/TestCaseDTO.java @@ -0,0 +1,55 @@ +package com.github.codehive.model.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TestCaseDTO { + private UUID id; + private UUID assignmentId; + private Integer order; + private Boolean isSample; + private LocalDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public Integer getOrder() { + return order; + } + + public void setOrder(Integer order) { + this.order = order; + } + + public Boolean getIsSample() { + return isSample; + } + + public void setIsSample(Boolean isSample) { + this.isSample = isSample; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/UserDTO.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/UserDTO.java index abc58ba..d1f6d1d 100644 --- a/codehive-backend/src/main/java/com/github/codehive/model/dto/UserDTO.java +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/UserDTO.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import com.fasterxml.jackson.annotation.JsonInclude; import com.github.codehive.model.enums.Role; @@ -9,7 +10,7 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class UserDTO { - private Long id; + private UUID id; private String email; private String name; private String lastName; @@ -21,11 +22,11 @@ public class UserDTO { private Boolean isActive; private Boolean temporaryPassword; - public Long getId() { + public UUID getId() { return id; } - public void setId(Long id) { + public void setId(UUID id) { this.id = id; } diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionJob.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionJob.java new file mode 100644 index 0000000..e46fffe --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionJob.java @@ -0,0 +1,149 @@ +package com.github.codehive.model.dto.queue; + +import java.util.List; +import java.util.UUID; + +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.ExecutionType; +import com.github.codehive.model.enums.Language; + + +public class ExecutionJob { + private UUID id; + private String source; + private String reference; + private Language language; + private ExecutionType executionType; + private List testCases; + private String outputPath; + private Long timeLimitMs; + private Long memoryLimitMb; + private Integer numTests; + private Language referenceLanguage; + private String testsPath; + private ComparatorType comparatorType; + + public ExecutionJob() { + } + + public ExecutionJob(UUID id, String source, String reference, Language language, + ExecutionType executionType, List testCases, + Long timeLimitMs, Long memoryLimitMb, ComparatorType comparatorType, String outputPath, Integer numTests, String testsPath, Language referenceLanguage) { + this.id = id; + this.source = source; + this.reference = reference; + this.language = language; + this.executionType = executionType; + this.testCases = testCases; + this.timeLimitMs = timeLimitMs; + this.memoryLimitMb = memoryLimitMb; + this.comparatorType = comparatorType; + this.outputPath = outputPath; + this.numTests = numTests; + this.testsPath = testsPath; + this.referenceLanguage = referenceLanguage; + } + + public Language getReferenceLanguage() { + return referenceLanguage; + } + + public void setReferenceLanguage(Language referenceLanguage) { + this.referenceLanguage = referenceLanguage; + } + + public String getTestsPath() { + return testsPath; + } + + public void setTestsPath(String testsPath) { + this.testsPath = testsPath; + } + + public Integer getNumTests() { + return numTests; + } + public void setNumTests(Integer numTests) { + this.numTests = numTests; + } + + public String getOutputPath() { + return outputPath; + } + + public void setOutputPath(String outputPath) { + this.outputPath = outputPath; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } + + public ExecutionType getExecutionType() { + return executionType; + } + + public void setExecutionType(ExecutionType executionType) { + this.executionType = executionType; + } + + public List getTestCases() { + return testCases; + } + + public void setTestCases(List testCases) { + this.testCases = testCases; + } + + public Long getTimeLimitMs() { + return timeLimitMs; + } + + public void setTimeLimitMs(Long timeLimitMs) { + this.timeLimitMs = timeLimitMs; + } + + public Long getMemoryLimitMb() { + return memoryLimitMb; + } + + public void setMemoryLimitMb(Long memoryLimitMb) { + this.memoryLimitMb = memoryLimitMb; + } + + public ComparatorType getComparatorType() { + return comparatorType; + } + + public void setComparatorType(ComparatorType comparatorType) { + this.comparatorType = comparatorType; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionReport.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionReport.java new file mode 100644 index 0000000..96361fb --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/ExecutionReport.java @@ -0,0 +1,110 @@ +package com.github.codehive.model.dto.queue; + +import java.util.UUID; + +import com.github.codehive.model.enums.ExecutionStatus; + +import java.util.ArrayList; +import java.util.List; + +public class ExecutionReport { + private UUID executionId; + private ExecutionStatus overallStatus; + private List testCaseResults; + private int totalTests; + private int passedTests; + private int failedTests; + private Long totalExecutionTimeMs; + private Long maxExecutionTimeMs; + private Long maxMemoryUsedMb; + private String compilationError; + + public ExecutionReport() { + this.testCaseResults = new ArrayList<>(); + } + + public ExecutionReport(UUID executionId) { + this.executionId = executionId; + this.testCaseResults = new ArrayList<>(); + } + + public UUID getExecutionId() { + return executionId; + } + + public void setExecutionId(UUID executionId) { + this.executionId = executionId; + } + + public ExecutionStatus getOverallStatus() { + return overallStatus; + } + + public void setOverallStatus(ExecutionStatus overallStatus) { + this.overallStatus = overallStatus; + } + + public List getTestCaseResults() { + return testCaseResults; + } + + public void setTestCaseResults(List testCaseResults) { + this.testCaseResults = testCaseResults; + } + + public int getTotalTests() { + return totalTests; + } + + public void setTotalTests(int totalTests) { + this.totalTests = totalTests; + } + + public int getPassedTests() { + return passedTests; + } + + public void setPassedTests(int passedTests) { + this.passedTests = passedTests; + } + + public int getFailedTests() { + return failedTests; + } + + public void setFailedTests(int failedTests) { + this.failedTests = failedTests; + } + + public Long getTotalExecutionTimeMs() { + return totalExecutionTimeMs; + } + + public void setTotalExecutionTimeMs(Long totalExecutionTimeMs) { + this.totalExecutionTimeMs = totalExecutionTimeMs; + } + + public Long getMaxExecutionTimeMs() { + return maxExecutionTimeMs; + } + + public void setMaxExecutionTimeMs(Long maxExecutionTimeMs) { + this.maxExecutionTimeMs = maxExecutionTimeMs; + } + + public Long getMaxMemoryUsedMb() { + return maxMemoryUsedMb; + } + + public void setMaxMemoryUsedMb(Long maxMemoryUsedMb) { + this.maxMemoryUsedMb = maxMemoryUsedMb; + } + + public String getCompilationError() { + return compilationError; + } + + public void setCompilationError(String compilationError) { + this.compilationError = compilationError; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseInfo.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseInfo.java new file mode 100644 index 0000000..7687fd8 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseInfo.java @@ -0,0 +1,42 @@ +package com.github.codehive.model.dto.queue; + +import java.util.UUID; + +public class TestCaseInfo { + private UUID testCaseId; + private String inputPath; + private String outputPath; + + public TestCaseInfo() { + } + + public TestCaseInfo(UUID testCaseId, String inputPath, String outputPath) { + this.testCaseId = testCaseId; + this.inputPath = inputPath; + this.outputPath = outputPath; + } + + public UUID getTestCaseId() { + return testCaseId; + } + + public void setTestCaseId(UUID testCaseId) { + this.testCaseId = testCaseId; + } + + public String getInputPath() { + return inputPath; + } + + public void setInputPath(String inputPath) { + this.inputPath = inputPath; + } + + public String getOutputPath() { + return outputPath; + } + + public void setOutputPath(String outputPath) { + this.outputPath = outputPath; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseResult.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseResult.java new file mode 100644 index 0000000..2c8e5c9 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestCaseResult.java @@ -0,0 +1,80 @@ +package com.github.codehive.model.dto.queue; + +import com.github.codehive.model.enums.ExecutionStatus; + +public class TestCaseResult { + private int testCaseNumber; + private ExecutionStatus status; + private Long executionTimeMs; + private Long memoryUsedMb; + private String feedback; + private String expectedOutput; + private String actualOutput; + + public TestCaseResult() { + } + + public TestCaseResult(int testCaseNumber, ExecutionStatus status, + Long executionTimeMs, Long memoryUsedMb) { + this.testCaseNumber = testCaseNumber; + this.status = status; + this.executionTimeMs = executionTimeMs; + this.memoryUsedMb = memoryUsedMb; + } + + public int getTestCaseNumber() { + return testCaseNumber; + } + + public void setTestCaseNumber(int testCaseNumber) { + this.testCaseNumber = testCaseNumber; + } + + public ExecutionStatus getStatus() { + return status; + } + + public void setStatus(ExecutionStatus status) { + this.status = status; + } + + public Long getExecutionTimeMs() { + return executionTimeMs; + } + + public void setExecutionTimeMs(Long executionTimeMs) { + this.executionTimeMs = executionTimeMs; + } + + public Long getMemoryUsedMb() { + return memoryUsedMb; + } + + public void setMemoryUsedMb(Long memoryUsedMb) { + this.memoryUsedMb = memoryUsedMb; + } + + public String getFeedback() { + return feedback; + } + + public void setFeedback(String feedback) { + this.feedback = feedback; + } + + public String getExpectedOutput() { + return expectedOutput; + } + + public void setExpectedOutput(String expectedOutput) { + this.expectedOutput = expectedOutput; + } + + public String getActualOutput() { + return actualOutput; + } + + public void setActualOutput(String actualOutput) { + this.actualOutput = actualOutput; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationJob.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationJob.java new file mode 100644 index 0000000..cb848ef --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationJob.java @@ -0,0 +1,76 @@ +package com.github.codehive.model.dto.queue; + +import java.util.List; +import java.util.UUID; + +import com.github.codehive.model.enums.Language; + +public class TestGenerationJob { + private UUID assignmentId; + private String referenceSolutionPath; + private Language referenceLanguage; + private List testCases; + private Long timeLimitMs; + private Long memoryLimitMb; + + public TestGenerationJob() { + } + + public TestGenerationJob(UUID assignmentId, String referenceSolutionPath, Language referenceLanguage, + List testCases, Long timeLimitMs, Long memoryLimitMb) { + this.assignmentId = assignmentId; + this.referenceSolutionPath = referenceSolutionPath; + this.referenceLanguage = referenceLanguage; + this.testCases = testCases; + this.timeLimitMs = timeLimitMs; + this.memoryLimitMb = memoryLimitMb; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public String getReferenceSolutionPath() { + return referenceSolutionPath; + } + + public void setReferenceSolutionPath(String referenceSolutionPath) { + this.referenceSolutionPath = referenceSolutionPath; + } + + public Language getReferenceLanguage() { + return referenceLanguage; + } + + public void setReferenceLanguage(Language referenceLanguage) { + this.referenceLanguage = referenceLanguage; + } + + public List getTestCases() { + return testCases; + } + + public void setTestCases(List testCases) { + this.testCases = testCases; + } + + public Long getTimeLimitMs() { + return timeLimitMs; + } + + public void setTimeLimitMs(Long timeLimitMs) { + this.timeLimitMs = timeLimitMs; + } + + public Long getMemoryLimitMb() { + return memoryLimitMb; + } + + public void setMemoryLimitMb(Long memoryLimitMb) { + this.memoryLimitMb = memoryLimitMb; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationResult.java b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationResult.java new file mode 100644 index 0000000..00e729f --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/dto/queue/TestGenerationResult.java @@ -0,0 +1,52 @@ +package com.github.codehive.model.dto.queue; + +import java.util.UUID; + +public class TestGenerationResult { + private UUID assignmentId; + private boolean success; + private int generatedCount; + private String errorMessage; + + public TestGenerationResult() { + } + + public TestGenerationResult(UUID assignmentId, boolean success, int generatedCount, String errorMessage) { + this.assignmentId = assignmentId; + this.success = success; + this.generatedCount = generatedCount; + this.errorMessage = errorMessage; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public int getGeneratedCount() { + return generatedCount; + } + + public void setGeneratedCount(int generatedCount) { + this.generatedCount = generatedCount; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/Assignment.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/Assignment.java new file mode 100644 index 0000000..c65d89a --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/Assignment.java @@ -0,0 +1,209 @@ +package com.github.codehive.model.entity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.Language; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; + +@Entity +@Table(name = "assignments") +public class Assignment { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @ElementCollection + @CollectionTable(name = "assignment_constraints", joinColumns = @JoinColumn(name = "assignment_id")) + @Column(name = "constraint_text", columnDefinition = "TEXT") + private List constraints = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "assignment_hints", joinColumns = @JoinColumn(name = "assignment_id")) + @Column(name = "hint_text", columnDefinition = "TEXT") + private List hints = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "assignment_tags", joinColumns = @JoinColumn(name = "assignment_id")) + @Column(name = "tag", length = 50) + private List tags = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "assignment_allowed_languages", joinColumns = @JoinColumn(name = "assignment_id")) + @Column(name = "language", length = 20) + @Enumerated(EnumType.STRING) + private List allowedLanguages; + + @Column(nullable = false) + private Long timeLimitMs; + + @Column(nullable = false) + private Long memoryLimitMb; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private ComparatorType comparatorType; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @Column(nullable = true) + private LocalDateTime dueDate; + + @Column(nullable = false) + private Boolean isActive; + + public Assignment() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + this.constraints = new ArrayList<>(); + this.hints = new ArrayList<>(); + this.tags = new ArrayList<>(); + this.allowedLanguages = new ArrayList<>(); + this.isActive = true; + } + + public Assignment(String title, String description, Long timeLimitMs, Long memoryLimitMb, ComparatorType comparatorType) { + this(); + this.title = title; + this.description = description; + this.timeLimitMs = timeLimitMs; + this.memoryLimitMb = memoryLimitMb; + this.comparatorType = comparatorType; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public List getAllowedLanguages() { + return allowedLanguages; + } + + public void setAllowedLanguages(List allowedLanguages) { + this.allowedLanguages = allowedLanguages; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getConstraints() { + return constraints; + } + + public void setConstraints(List constraints) { + this.constraints = constraints; + } + + public List getHints() { + return hints; + } + + public void setHints(List hints) { + this.hints = hints; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public Long getTimeLimitMs() { + return timeLimitMs; + } + + public void setTimeLimitMs(Long timeLimitMs) { + this.timeLimitMs = timeLimitMs; + } + + public Long getMemoryLimitMb() { + return memoryLimitMb; + } + + public void setMemoryLimitMb(Long memoryLimitMb) { + this.memoryLimitMb = memoryLimitMb; + } + + public ComparatorType getComparatorType() { + return comparatorType; + } + + public void setComparatorType(ComparatorType comparatorType) { + this.comparatorType = comparatorType; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getDueDate() { + return dueDate; + } + + public void setDueDate(LocalDateTime dueDate) { + this.dueDate = dueDate; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/Execution.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/Execution.java new file mode 100644 index 0000000..c1dc9e9 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/Execution.java @@ -0,0 +1,151 @@ +package com.github.codehive.model.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.github.codehive.model.enums.ExecutionStatus; +import com.github.codehive.model.enums.ExecutionType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "executions") +public class Execution { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "submission_id", nullable = true, unique = true) + private Submission submission; // NULLABLE for PRACTICE executions + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = true) + private User user; // User who initiated the execution + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private ExecutionType executionType; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private ExecutionStatus status; + + @Column(nullable = true) + private Long timeMs; + + @Column(nullable = true) + private Long memoryMb; + + @Column(nullable = false) + private Boolean isOutdated; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public Execution() { + this.createdAt = LocalDateTime.now(); + this.isOutdated = false; + this.status = ExecutionStatus.PENDING; + } + + public Execution(Submission submission, ExecutionType executionType) { + this(); + this.submission = submission; + this.executionType = executionType; + } + + public Execution(ExecutionType executionType) { + this(); + this.executionType = executionType; + } + + public Execution(ExecutionType executionType, User user) { + this(); + this.executionType = executionType; + this.user = user; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Submission getSubmission() { + return submission; + } + + public void setSubmission(Submission submission) { + this.submission = submission; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public ExecutionType getExecutionType() { + return executionType; + } + + public void setExecutionType(ExecutionType executionType) { + this.executionType = executionType; + } + + public ExecutionStatus getStatus() { + return status; + } + + public void setStatus(ExecutionStatus status) { + this.status = status; + } + + public Long getTimeMs() { + return timeMs; + } + + public void setTimeMs(Long timeMs) { + this.timeMs = timeMs; + } + + public Long getMemoryMb() { + return memoryMb; + } + + public void setMemoryMb(Long memoryMb) { + this.memoryMb = memoryMb; + } + + public Boolean getIsOutdated() { + return isOutdated; + } + + public void setIsOutdated(Boolean isOutdated) { + this.isOutdated = isOutdated; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/PasswordResetToken.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/PasswordResetToken.java index c6b24f4..195713b 100644 --- a/codehive-backend/src/main/java/com/github/codehive/model/entity/PasswordResetToken.java +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/PasswordResetToken.java @@ -1,6 +1,7 @@ package com.github.codehive.model.entity; import java.time.LocalDateTime; +import java.util.UUID; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -15,8 +16,8 @@ @Table(name = "password_reset_tokens") public class PasswordResetToken { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(nullable = false, unique = true) private String token; @@ -41,11 +42,11 @@ public PasswordResetToken(String token, LocalDateTime expiryDate, User user) { this.used = false; } - public Long getId() { + public UUID getId() { return id; } - public void setId(Long id) { + public void setId(UUID id) { this.id = id; } diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/ReferenceSolution.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/ReferenceSolution.java new file mode 100644 index 0000000..83db33c --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/ReferenceSolution.java @@ -0,0 +1,65 @@ +package com.github.codehive.model.entity; + +import java.util.UUID; + +import com.github.codehive.model.enums.Language; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "reference_solutions") +public class ReferenceSolution { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignment_id", nullable = false) + private Assignment assignment; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private Language language; + + public ReferenceSolution() { + } + + public ReferenceSolution(Assignment assignment, Language language) { + this.assignment = assignment; + this.language = language; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Assignment getAssignment() { + return assignment; + } + + public void setAssignment(Assignment assignment) { + this.assignment = assignment; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/Submission.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/Submission.java new file mode 100644 index 0000000..4610ba0 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/Submission.java @@ -0,0 +1,79 @@ +package com.github.codehive.model.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.github.codehive.model.enums.Language; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "submissions") +public class Submission { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignment_id", nullable = false) + private Assignment assignment; + + @Column(nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private Language language; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public Submission() { + this.createdAt = LocalDateTime.now(); + } + + public Submission(Assignment assignment, Language language) { + this(); + this.assignment = assignment; + this.language = language; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Assignment getAssignment() { + return assignment; + } + + public void setAssignment(Assignment assignment) { + this.assignment = assignment; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/TestCase.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/TestCase.java new file mode 100644 index 0000000..c19b3dc --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/TestCase.java @@ -0,0 +1,74 @@ +package com.github.codehive.model.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_cases") +public class TestCase { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignment_id", nullable = false) + private Assignment assignment; + + @Column(nullable = false, name = "order_index") + private Integer order; + + @Column(nullable = false) + private Boolean isSample; + + public TestCase() { + this.isSample = false; + } + + public TestCase(Assignment assignment, Integer order, Boolean isSample) { + this(); + this.assignment = assignment; + this.order = order; + this.isSample = isSample; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Assignment getAssignment() { + return assignment; + } + + public void setAssignment(Assignment assignment) { + this.assignment = assignment; + } + + public Integer getOrder() { + return order; + } + + public void setOrder(Integer order) { + this.order = order; + } + + public Boolean getIsSample() { + return isSample; + } + + public void setIsSample(Boolean isSample) { + this.isSample = isSample; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/entity/User.java b/codehive-backend/src/main/java/com/github/codehive/model/entity/User.java index 26edabf..a68fa78 100644 --- a/codehive-backend/src/main/java/com/github/codehive/model/entity/User.java +++ b/codehive-backend/src/main/java/com/github/codehive/model/entity/User.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.UUID; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -28,8 +29,8 @@ @Table(name = "users") public class User implements UserDetails { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; @Column(nullable = false, length = 50) private String name; @@ -85,11 +86,11 @@ public User(String name, String lastName, String enrollmentNumber, String email, this.scopes = new ArrayList<>(); } - public Long getId() { + public UUID getId() { return id; } - public void setId(Long id) { + public void setId(UUID id) { this.id = id; } diff --git a/codehive-backend/src/main/java/com/github/codehive/model/enums/ComparatorType.java b/codehive-backend/src/main/java/com/github/codehive/model/enums/ComparatorType.java new file mode 100644 index 0000000..a978681 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/enums/ComparatorType.java @@ -0,0 +1,6 @@ +package com.github.codehive.model.enums; + +public enum ComparatorType { + EXACT_MATCH, + FLOATING_POINT +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionStatus.java b/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionStatus.java new file mode 100644 index 0000000..53b4af5 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionStatus.java @@ -0,0 +1,12 @@ +package com.github.codehive.model.enums; + +public enum ExecutionStatus { + TLE, // Time Limit Exceeded + MLE, // Memory Limit Exceeded + OLE, // Output Limit Exceeded + RTE, // Runtime Error + CE, // Compilation Error + WA, // Wrong Answer + AC, // Accepted + PENDING +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionType.java b/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionType.java new file mode 100644 index 0000000..bc2355c --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/enums/ExecutionType.java @@ -0,0 +1,6 @@ +package com.github.codehive.model.enums; + +public enum ExecutionType { + PRACTICE, + DEFINITIVE +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/enums/Language.java b/codehive-backend/src/main/java/com/github/codehive/model/enums/Language.java new file mode 100644 index 0000000..623da92 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/enums/Language.java @@ -0,0 +1,5 @@ +package com.github.codehive.model.enums; + +public enum Language { + C, CPP, JAVA, PYTHON +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/mapper/AssignmentMapper.java b/codehive-backend/src/main/java/com/github/codehive/model/mapper/AssignmentMapper.java new file mode 100644 index 0000000..bad1c03 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/mapper/AssignmentMapper.java @@ -0,0 +1,60 @@ +package com.github.codehive.model.mapper; + +import java.util.List; + +import com.github.codehive.model.dto.AssignmentDTO; +import com.github.codehive.model.entity.Assignment; + +public class AssignmentMapper { + public static AssignmentDTO toDTO(Assignment assignment) { + if (assignment == null) { + return null; + } + AssignmentDTO dto = new AssignmentDTO(); + dto.setId(assignment.getId()); + dto.setTitle(assignment.getTitle()); + dto.setDescription(assignment.getDescription()); + dto.setConstraints(assignment.getConstraints()); + dto.setHints(assignment.getHints()); + dto.setTags(assignment.getTags()); + dto.setTimeLimitMs(assignment.getTimeLimitMs()); + dto.setMemoryLimitMb(assignment.getMemoryLimitMb()); + dto.setComparatorType(assignment.getComparatorType()); + dto.setCreatedAt(assignment.getCreatedAt()); + dto.setUpdatedAt(assignment.getUpdatedAt()); + dto.setDueDate(assignment.getDueDate()); + dto.setAllowedLanguages(assignment.getAllowedLanguages()); + dto.setIsActive(assignment.getIsActive()); + return dto; + } + + public static Assignment toEntity(AssignmentDTO dto) { + if (dto == null) { + return null; + } + Assignment assignment = new Assignment(); + assignment.setId(dto.getId()); + assignment.setTitle(dto.getTitle()); + assignment.setDescription(dto.getDescription()); + assignment.setConstraints(dto.getConstraints()); + assignment.setHints(dto.getHints()); + assignment.setTags(dto.getTags()); + assignment.setTimeLimitMs(dto.getTimeLimitMs()); + assignment.setMemoryLimitMb(dto.getMemoryLimitMb()); + assignment.setComparatorType(dto.getComparatorType()); + assignment.setCreatedAt(dto.getCreatedAt()); + assignment.setUpdatedAt(dto.getUpdatedAt()); + assignment.setDueDate(dto.getDueDate()); + assignment.setAllowedLanguages(dto.getAllowedLanguages()); + assignment.setIsActive(dto.getIsActive()); + return assignment; + } + + public static List toDTOList(List assignments) { + return assignments.stream().map(AssignmentMapper::toDTO).toList(); + } + + public static List toEntityList(List dtos) { + return dtos.stream().map(AssignmentMapper::toEntity).toList(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/mapper/ExecutionMapper.java b/codehive-backend/src/main/java/com/github/codehive/model/mapper/ExecutionMapper.java new file mode 100644 index 0000000..154b6fe --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/mapper/ExecutionMapper.java @@ -0,0 +1,51 @@ +package com.github.codehive.model.mapper; + +import java.util.List; + +import com.github.codehive.model.dto.ExecutionDTO; +import com.github.codehive.model.entity.Execution; + +public class ExecutionMapper { + public static ExecutionDTO toDTO(Execution execution) { + if (execution == null) { + return null; + } + ExecutionDTO dto = new ExecutionDTO(); + dto.setId(execution.getId()); + dto.setSubmissionId(execution.getSubmission() != null ? + execution.getSubmission().getId() : null); + dto.setUserId(execution.getUser() != null ? + execution.getUser().getId() : null); + dto.setExecutionType(execution.getExecutionType()); + dto.setStatus(execution.getStatus()); + dto.setTimeMs(execution.getTimeMs()); + dto.setMemoryMb(execution.getMemoryMb()); + dto.setIsOutdated(execution.getIsOutdated()); + dto.setCreatedAt(execution.getCreatedAt()); + return dto; + } + + public static Execution toEntity(ExecutionDTO dto) { + if (dto == null) { + return null; + } + Execution execution = new Execution(); + execution.setId(dto.getId()); + // Note: Submission and User must be set separately via their repositories + execution.setExecutionType(dto.getExecutionType()); + execution.setStatus(dto.getStatus()); + execution.setTimeMs(dto.getTimeMs()); + execution.setMemoryMb(dto.getMemoryMb()); + execution.setIsOutdated(dto.getIsOutdated()); + execution.setCreatedAt(dto.getCreatedAt()); + return execution; + } + + public static List toDTOList(List executions) { + return executions.stream().map(ExecutionMapper::toDTO).toList(); + } + + public static List toEntityList(List dtos) { + return dtos.stream().map(ExecutionMapper::toEntity).toList(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/mapper/ReferenceSolutionMapper.java b/codehive-backend/src/main/java/com/github/codehive/model/mapper/ReferenceSolutionMapper.java new file mode 100644 index 0000000..8f4016f --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/mapper/ReferenceSolutionMapper.java @@ -0,0 +1,39 @@ +package com.github.codehive.model.mapper; + +import java.util.List; + +import com.github.codehive.model.dto.ReferenceSolutionDTO; +import com.github.codehive.model.entity.ReferenceSolution; + +public class ReferenceSolutionMapper { + public static ReferenceSolutionDTO toDTO(ReferenceSolution referenceSolution) { + if (referenceSolution == null) { + return null; + } + ReferenceSolutionDTO dto = new ReferenceSolutionDTO(); + dto.setId(referenceSolution.getId()); + dto.setAssignmentId(referenceSolution.getAssignment() != null ? + referenceSolution.getAssignment().getId() : null); + dto.setLanguage(referenceSolution.getLanguage()); + return dto; + } + + public static ReferenceSolution toEntity(ReferenceSolutionDTO dto) { + if (dto == null) { + return null; + } + ReferenceSolution referenceSolution = new ReferenceSolution(); + referenceSolution.setId(dto.getId()); + // Note: Assignment must be set separately via assignment repository + referenceSolution.setLanguage(dto.getLanguage()); + return referenceSolution; + } + + public static List toDTOList(List referenceSolutions) { + return referenceSolutions.stream().map(ReferenceSolutionMapper::toDTO).toList(); + } + + public static List toEntityList(List dtos) { + return dtos.stream().map(ReferenceSolutionMapper::toEntity).toList(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/mapper/SubmissionMapper.java b/codehive-backend/src/main/java/com/github/codehive/model/mapper/SubmissionMapper.java new file mode 100644 index 0000000..c924475 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/mapper/SubmissionMapper.java @@ -0,0 +1,41 @@ +package com.github.codehive.model.mapper; + +import java.util.List; + +import com.github.codehive.model.dto.SubmissionDTO; +import com.github.codehive.model.entity.Submission; + +public class SubmissionMapper { + public static SubmissionDTO toDTO(Submission submission) { + if (submission == null) { + return null; + } + SubmissionDTO dto = new SubmissionDTO(); + dto.setId(submission.getId()); + dto.setAssignmentId(submission.getAssignment() != null ? + submission.getAssignment().getId() : null); + dto.setLanguage(submission.getLanguage()); + dto.setCreatedAt(submission.getCreatedAt()); + return dto; + } + + public static Submission toEntity(SubmissionDTO dto) { + if (dto == null) { + return null; + } + Submission submission = new Submission(); + submission.setId(dto.getId()); + // Note: Assignment must be set separately via assignment repository + submission.setLanguage(dto.getLanguage()); + submission.setCreatedAt(dto.getCreatedAt()); + return submission; + } + + public static List toDTOList(List submissions) { + return submissions.stream().map(SubmissionMapper::toDTO).toList(); + } + + public static List toEntityList(List dtos) { + return dtos.stream().map(SubmissionMapper::toEntity).toList(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/mapper/TestCaseMapper.java b/codehive-backend/src/main/java/com/github/codehive/model/mapper/TestCaseMapper.java new file mode 100644 index 0000000..bff2b58 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/mapper/TestCaseMapper.java @@ -0,0 +1,41 @@ +package com.github.codehive.model.mapper; + +import java.util.List; + +import com.github.codehive.model.dto.TestCaseDTO; +import com.github.codehive.model.entity.TestCase; + +public class TestCaseMapper { + public static TestCaseDTO toDTO(TestCase testCase) { + if (testCase == null) { + return null; + } + TestCaseDTO dto = new TestCaseDTO(); + dto.setId(testCase.getId()); + dto.setAssignmentId(testCase.getAssignment() != null ? + testCase.getAssignment().getId() : null); + dto.setOrder(testCase.getOrder()); + dto.setIsSample(testCase.getIsSample()); + return dto; + } + + public static TestCase toEntity(TestCaseDTO dto) { + if (dto == null) { + return null; + } + TestCase testCase = new TestCase(); + testCase.setId(dto.getId()); + // Note: Assignment must be set separately via assignment repository + testCase.setOrder(dto.getOrder()); + testCase.setIsSample(dto.getIsSample()); + return testCase; + } + + public static List toDTOList(List testCases) { + return testCases.stream().map(TestCaseMapper::toDTO).toList(); + } + + public static List toEntityList(List dtos) { + return dtos.stream().map(TestCaseMapper::toEntity).toList(); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/request/assignment/CreateAssignmentRequest.java b/codehive-backend/src/main/java/com/github/codehive/model/request/assignment/CreateAssignmentRequest.java new file mode 100644 index 0000000..3216ce6 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/request/assignment/CreateAssignmentRequest.java @@ -0,0 +1,144 @@ +package com.github.codehive.model.request.assignment; + +import java.time.LocalDateTime; +import java.util.List; + +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.Language; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public class CreateAssignmentRequest { + + @NotBlank(message = "Title is required") + private String title; + + @NotBlank(message = "Description is required") + private String description; + + private List constraints; + private List hints; + private List tags; + + @NotNull(message = "Time limit is required") + @Min(value = 100, message = "Time limit must be at least 100ms") + private Long timeLimitMs; + + @NotNull(message = "Memory limit is required") + @Min(value = 16, message = "Memory limit must be at least 16MB") + private Long memoryLimitMb; + + @NotNull(message = "Comparator type is required") + private ComparatorType comparatorType; + + @NotEmpty(message = "At least one allowed language is required") + private List allowedLanguages; + + @NotNull(message = "Reference language is required") + private Language referenceLanguage; + + private LocalDateTime dueDate; + + // Parallel list indicating whether each uploaded test case input is a sample. + // If null or shorter than the number of uploaded files, remaining cases default to non-sample. + private List sampleFlags; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getConstraints() { + return constraints; + } + + public void setConstraints(List constraints) { + this.constraints = constraints; + } + + public List getHints() { + return hints; + } + + public void setHints(List hints) { + this.hints = hints; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + + public Long getTimeLimitMs() { + return timeLimitMs; + } + + public void setTimeLimitMs(Long timeLimitMs) { + this.timeLimitMs = timeLimitMs; + } + + public Long getMemoryLimitMb() { + return memoryLimitMb; + } + + public void setMemoryLimitMb(Long memoryLimitMb) { + this.memoryLimitMb = memoryLimitMb; + } + + public ComparatorType getComparatorType() { + return comparatorType; + } + + public void setComparatorType(ComparatorType comparatorType) { + this.comparatorType = comparatorType; + } + + public List getAllowedLanguages() { + return allowedLanguages; + } + + public void setAllowedLanguages(List allowedLanguages) { + this.allowedLanguages = allowedLanguages; + } + + public Language getReferenceLanguage() { + return referenceLanguage; + } + + public void setReferenceLanguage(Language referenceLanguage) { + this.referenceLanguage = referenceLanguage; + } + + public LocalDateTime getDueDate() { + return dueDate; + } + + public void setDueDate(LocalDateTime dueDate) { + this.dueDate = dueDate; + } + + public List getSampleFlags() { + return sampleFlags; + } + + public void setSampleFlags(List sampleFlags) { + this.sampleFlags = sampleFlags; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/request/auth/SignUpRequest.java b/codehive-backend/src/main/java/com/github/codehive/model/request/auth/SignUpRequest.java index 29eba8e..bb33c10 100644 --- a/codehive-backend/src/main/java/com/github/codehive/model/request/auth/SignUpRequest.java +++ b/codehive-backend/src/main/java/com/github/codehive/model/request/auth/SignUpRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public class SignUpRequest { @@ -24,6 +25,10 @@ public class SignUpRequest { private String motherLastName; @NotBlank(message = "Enrollment number is required") + @Pattern( + regexp = "^(199[4-9]|[2-9]\\d{3})630\\d{3}$", + message = "Enrollment number must be 10 digits: year (>=1994), followed by 630, followed by any 3 digits" + ) private String enrollmentNumber; @NotBlank(message = "Email is required") diff --git a/codehive-backend/src/main/java/com/github/codehive/model/request/execution/ExecutionRequest.java b/codehive-backend/src/main/java/com/github/codehive/model/request/execution/ExecutionRequest.java new file mode 100644 index 0000000..b48aa98 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/request/execution/ExecutionRequest.java @@ -0,0 +1,88 @@ +package com.github.codehive.model.request.execution; + +import java.util.List; +import java.util.UUID; + +import com.github.codehive.model.enums.ExecutionType; +import com.github.codehive.model.enums.Language; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class ExecutionRequest { + @NotBlank(message = "Code is required") + private String code; + + @NotNull(message = "Language is required") + private Language language; + + private UUID requesterId; + + private UUID assignmentId; + + private List testCases; + + @NotNull(message = "Execution type is required") + private ExecutionType executionType; + + public ExecutionRequest() { + } + + public ExecutionRequest(String code, Language language, UUID requesterId, + UUID assignmentId, List testCases, ExecutionType executionType) { + this.code = code; + this.language = language; + this.requesterId = requesterId; + this.assignmentId = assignmentId; + this.testCases = testCases; + this.executionType = executionType; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public Language getLanguage() { + return language; + } + + public void setLanguage(Language language) { + this.language = language; + } + + public UUID getRequesterId() { + return requesterId; + } + + public void setRequesterId(UUID requesterId) { + this.requesterId = requesterId; + } + + public UUID getAssignmentId() { + return assignmentId; + } + + public void setAssignmentId(UUID assignmentId) { + this.assignmentId = assignmentId; + } + + public List getTestCases() { + return testCases; + } + + public void setTestCases(List testCases) { + this.testCases = testCases; + } + + public ExecutionType getExecutionType() { + return executionType; + } + + public void setExecutionType(ExecutionType executionType) { + this.executionType = executionType; + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/model/response/PageResponse.java b/codehive-backend/src/main/java/com/github/codehive/model/response/PageResponse.java new file mode 100644 index 0000000..e5a0d42 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/model/response/PageResponse.java @@ -0,0 +1,30 @@ +package com.github.codehive.model.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +public class PageResponse { + private final List content; + private final int page; + private final int size; + private final long totalElements; + private final int totalPages; + private final boolean last; + + public PageResponse(Page page) { + this.content = page.getContent(); + this.page = page.getNumber(); + this.size = page.getSize(); + this.totalElements = page.getTotalElements(); + this.totalPages = page.getTotalPages(); + this.last = page.isLast(); + } + + public List getContent() { return content; } + public int getPage() { return page; } + public int getSize() { return size; } + public long getTotalElements() { return totalElements; } + public int getTotalPages() { return totalPages; } + public boolean isLast() { return last; } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/AssignmentRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/AssignmentRepository.java new file mode 100644 index 0000000..51d39d6 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/repository/AssignmentRepository.java @@ -0,0 +1,26 @@ +package com.github.codehive.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.github.codehive.model.entity.Assignment; + +public interface AssignmentRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + List findByTitleContainingIgnoreCase(String title); + + List findByDueDateBefore(LocalDateTime date); + + List findByDueDateAfter(LocalDateTime date); + + Optional findByIdAndDueDateAfter(UUID id, LocalDateTime date); +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/ExecutionRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/ExecutionRepository.java new file mode 100644 index 0000000..23765a5 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/repository/ExecutionRepository.java @@ -0,0 +1,29 @@ +package com.github.codehive.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.github.codehive.model.entity.Execution; +import com.github.codehive.model.enums.ExecutionStatus; +import com.github.codehive.model.enums.ExecutionType; + +public interface ExecutionRepository extends JpaRepository { + List findByExecutionType(ExecutionType executionType); + + List findByStatus(ExecutionStatus status); + + List findByExecutionTypeAndStatus(ExecutionType executionType, ExecutionStatus status); + + List findByIsOutdated(Boolean isOutdated); + + List findByCreatedAtBefore(LocalDateTime date); + + List findByCreatedAtAfter(LocalDateTime date); + + List findBySubmissionIsNull(); + + long countByStatus(ExecutionStatus status); +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/PasswordResetTokenRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/PasswordResetTokenRepository.java index 1b45bc6..0bf651b 100644 --- a/codehive-backend/src/main/java/com/github/codehive/repository/PasswordResetTokenRepository.java +++ b/codehive-backend/src/main/java/com/github/codehive/repository/PasswordResetTokenRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,7 +11,7 @@ import com.github.codehive.model.entity.User; @Repository -public interface PasswordResetTokenRepository extends JpaRepository { +public interface PasswordResetTokenRepository extends JpaRepository { Optional findByToken(String token); List findByUserAndUsedFalse(User user); diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/ReferenceSolutionRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/ReferenceSolutionRepository.java new file mode 100644 index 0000000..59870af --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/repository/ReferenceSolutionRepository.java @@ -0,0 +1,21 @@ +package com.github.codehive.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.ReferenceSolution; +import com.github.codehive.model.enums.Language; + +public interface ReferenceSolutionRepository extends JpaRepository { + List findByAssignment(Assignment assignment); + + Optional findByAssignmentAndLanguage(Assignment assignment, Language language); + + List findByAssignmentId(UUID assignmentId); + + boolean existsByAssignmentAndLanguage(Assignment assignment, Language language); +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/SubmissionRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/SubmissionRepository.java new file mode 100644 index 0000000..d03a116 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/repository/SubmissionRepository.java @@ -0,0 +1,24 @@ +package com.github.codehive.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.Submission; + +public interface SubmissionRepository extends JpaRepository { + List findByAssignment(Assignment assignment); + + List findByAssignmentId(UUID assignmentId); + + List findByAssignmentIdOrderByCreatedAtDesc(UUID assignmentId); + + List findByCreatedAtBefore(LocalDateTime date); + + List findByCreatedAtAfter(LocalDateTime date); + + long countByAssignmentId(UUID assignmentId); +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/TestCaseRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/TestCaseRepository.java new file mode 100644 index 0000000..732ba05 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/repository/TestCaseRepository.java @@ -0,0 +1,25 @@ +package com.github.codehive.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.TestCase; + +public interface TestCaseRepository extends JpaRepository { + List findByAssignment(Assignment assignment); + + List findByAssignmentId(UUID assignmentId); + + List findByAssignmentOrderByOrderAsc(Assignment assignment); + + List findByAssignmentIdOrderByOrderAsc(UUID assignmentId); + + List findByAssignmentAndIsSample(Assignment assignment, Boolean isSample); + + List findByAssignmentIdAndIsSample(UUID assignmentId, Boolean isSample); + + long countByAssignmentId(UUID assignmentId); +} diff --git a/codehive-backend/src/main/java/com/github/codehive/repository/UserRepository.java b/codehive-backend/src/main/java/com/github/codehive/repository/UserRepository.java index f5c6ed1..4ec2d14 100644 --- a/codehive-backend/src/main/java/com/github/codehive/repository/UserRepository.java +++ b/codehive-backend/src/main/java/com/github/codehive/repository/UserRepository.java @@ -2,12 +2,13 @@ import java.util.List; import java.util.Optional; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import com.github.codehive.model.entity.User; -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { Optional findByEmail(String email); Optional findByEnrollmentNumber(String enrollmentNumber); diff --git a/codehive-backend/src/main/java/com/github/codehive/service/AssignmentService.java b/codehive-backend/src/main/java/com/github/codehive/service/AssignmentService.java new file mode 100644 index 0000000..2b0ddb4 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/service/AssignmentService.java @@ -0,0 +1,171 @@ +package com.github.codehive.service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.github.codehive.messaging.producer.TestGenerationRequestProducer; +import java.util.UUID; + +import com.github.codehive.model.dto.AssignmentDTO; +import com.github.codehive.model.dto.SampleTestCaseDTO; +import com.github.codehive.model.exception.EntityNotFoundException; +import com.github.codehive.model.dto.queue.TestCaseInfo; +import com.github.codehive.model.dto.queue.TestGenerationJob; +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.ReferenceSolution; +import com.github.codehive.model.entity.TestCase; +import com.github.codehive.model.mapper.AssignmentMapper; +import com.github.codehive.model.request.assignment.CreateAssignmentRequest; +import com.github.codehive.repository.AssignmentRepository; +import com.github.codehive.repository.ReferenceSolutionRepository; +import com.github.codehive.repository.TestCaseRepository; +import com.github.codehive.utils.FileExtensionUtil; +import com.github.codehive.utils.ObjectKeyBuilder; + +@Service +public class AssignmentService { + private static final Logger logger = LoggerFactory.getLogger(AssignmentService.class); + + private final AssignmentRepository assignmentRepository; + private final TestCaseRepository testCaseRepository; + private final ReferenceSolutionRepository referenceSolutionRepository; + private final ObjectStorageService objectStorageService; + private final TestGenerationRequestProducer testGenerationRequestProducer; + + public AssignmentService(AssignmentRepository assignmentRepository, + TestCaseRepository testCaseRepository, + ReferenceSolutionRepository referenceSolutionRepository, + ObjectStorageService objectStorageService, + TestGenerationRequestProducer testGenerationRequestProducer) { + this.assignmentRepository = assignmentRepository; + this.testCaseRepository = testCaseRepository; + this.referenceSolutionRepository = referenceSolutionRepository; + this.objectStorageService = objectStorageService; + this.testGenerationRequestProducer = testGenerationRequestProducer; + } + + public Page listAssignments(int page, int size) { + return assignmentRepository + .findAllByOrderByCreatedAtDesc(PageRequest.of(page, size)) + .map(AssignmentMapper::toDTO); + } + + public AssignmentDTO getAssignmentById(UUID id) { + Assignment assignment = assignmentRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Assignment not found: " + id)); + AssignmentDTO dto = AssignmentMapper.toDTO(assignment); + + // Fetch sample test cases and their inputs from MinIO + List samples = testCaseRepository + .findByAssignmentIdAndIsSample(id, true) + .stream() + .sorted(Comparator.comparing(TestCase::getOrder)) + .toList(); + + List sampleDTOs = new ArrayList<>(); + for (TestCase tc : samples) { + try { + String inputPath = ObjectKeyBuilder.testCaseInput(id, tc.getId()); + InputStream is = objectStorageService.download(inputPath); + String input = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + sampleDTOs.add(new SampleTestCaseDTO(tc.getOrder(), input)); + } catch (Exception e) { + logger.warn("Failed to fetch sample test case input: tcId={}", tc.getId(), e); + } + } + if (!sampleDTOs.isEmpty()) { + dto.setSampleTestCases(sampleDTOs); + } + return dto; + } + + @Transactional + public AssignmentDTO createAssignment(CreateAssignmentRequest request, + MultipartFile referenceSolutionFile, + List testCaseInputFiles) { + // Persist the assignment as inactive until output generation completes + Assignment assignment = new Assignment( + request.getTitle(), + request.getDescription(), + request.getTimeLimitMs(), + request.getMemoryLimitMb(), + request.getComparatorType() + ); + assignment.setConstraints(request.getConstraints() != null ? request.getConstraints() : new ArrayList<>()); + assignment.setHints(request.getHints() != null ? request.getHints() : new ArrayList<>()); + assignment.setTags(request.getTags() != null ? request.getTags() : new ArrayList<>()); + assignment.setAllowedLanguages(request.getAllowedLanguages()); + assignment.setDueDate(request.getDueDate()); + assignment.setIsActive(false); + + assignment = assignmentRepository.save(assignment); + logger.info("Assignment created: id={}, title={}", assignment.getId(), assignment.getTitle()); + + // Persist reference solution metadata + ReferenceSolution referenceSolution = new ReferenceSolution(assignment, request.getReferenceLanguage()); + referenceSolution = referenceSolutionRepository.save(referenceSolution); + logger.info("ReferenceSolution created: id={}, language={}", referenceSolution.getId(), referenceSolution.getLanguage()); + + // Upload reference solution file to MinIO + String refExtension = FileExtensionUtil.getFileExtensionByLanguage(request.getReferenceLanguage()); + String refPath = ObjectKeyBuilder.referenceSolutionSourceCode(assignment.getId(), refExtension); + uploadMultipartFile(refPath, referenceSolutionFile); + logger.info("Reference solution uploaded: path={}", refPath); + + // Persist test cases and upload inputs; build job payload + List testCaseInfos = new ArrayList<>(); + for (int i = 0; i < testCaseInputFiles.size(); i++) { + boolean isSample = request.getSampleFlags() != null + && i < request.getSampleFlags().size() + && Boolean.TRUE.equals(request.getSampleFlags().get(i)); + + TestCase testCase = new TestCase(assignment, i + 1, isSample); + testCase = testCaseRepository.save(testCase); + logger.info("TestCase created: id={}, order={}, isSample={}", testCase.getId(), testCase.getOrder(), testCase.getIsSample()); + + String inputPath = ObjectKeyBuilder.testCaseInput(assignment.getId(), testCase.getId()); + String outputPath = ObjectKeyBuilder.testCaseOutput(assignment.getId(), testCase.getId()); + + uploadMultipartFile(inputPath, testCaseInputFiles.get(i)); + logger.info("Test case input uploaded: path={}", inputPath); + + testCaseInfos.add(new TestCaseInfo(testCase.getId(), inputPath, outputPath)); + } + + // Publish test generation job + TestGenerationJob job = new TestGenerationJob( + assignment.getId(), + refPath, + request.getReferenceLanguage(), + testCaseInfos, + request.getTimeLimitMs(), + request.getMemoryLimitMb() + ); + testGenerationRequestProducer.sendTestGenerationRequest(job); + logger.info("Test generation job published for assignmentId={}", assignment.getId()); + + return AssignmentMapper.toDTO(assignment); + } + + private void uploadMultipartFile(String path, MultipartFile file) { + try { + objectStorageService.upload(path, new String(file.getBytes())); + } catch (IOException e) { + throw new RuntimeException("Failed to read uploaded file: " + file.getOriginalFilename(), e); + } catch (Exception e) { + throw new RuntimeException("Failed to upload file to object storage: " + path, e); + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/service/AuthService.java b/codehive-backend/src/main/java/com/github/codehive/service/AuthService.java index a7ddef3..62659c6 100644 --- a/codehive-backend/src/main/java/com/github/codehive/service/AuthService.java +++ b/codehive-backend/src/main/java/com/github/codehive/service/AuthService.java @@ -11,8 +11,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.regex.Pattern; +import io.jsonwebtoken.ExpiredJwtException; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -216,7 +218,21 @@ public CsvBulkRegisterResponse registerFromCsv(MultipartFile file) throws IOExce } public UserDTO getUserByToken(String token) { - String email = jwtUtil.extractClaim(token, claims -> claims.getSubject()); + try { + if (jwtUtil.isTokenExpired(token)) { + throw new IncorrectCredentialsException("Token has expired"); + } + String email = jwtUtil.extractClaim(token, claims -> claims.getSubject()); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IncorrectCredentialsException("User not found")); + return UserMapper.toDTO(user); + } catch (ExpiredJwtException e) { + throw new IncorrectCredentialsException("Token has expired"); + } + } + + @Transactional(readOnly = true) + public UserDTO getUserByEmail(String email) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new IncorrectCredentialsException("User not found")); return UserMapper.toDTO(user); @@ -230,7 +246,7 @@ private String generateToken(User user) { } @Transactional - public void updatePassword(Long userId, com.github.codehive.model.request.auth.UpdatePasswordRequest request) { + public void updatePassword(UUID userId, com.github.codehive.model.request.auth.UpdatePasswordRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new IncorrectCredentialsException("User not found")); diff --git a/codehive-backend/src/main/java/com/github/codehive/service/CsvRegistrationService.java b/codehive-backend/src/main/java/com/github/codehive/service/CsvRegistrationService.java index be43e73..f4dce81 100644 --- a/codehive-backend/src/main/java/com/github/codehive/service/CsvRegistrationService.java +++ b/codehive-backend/src/main/java/com/github/codehive/service/CsvRegistrationService.java @@ -37,6 +37,9 @@ public class CsvRegistrationService { private static final Pattern EMAIL_PATTERN = Pattern.compile( "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + private static final Pattern ENROLLMENT_PATTERN = Pattern.compile( + "^(199[4-9]|[2-9]\\d{3})630\\d{3}$"); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final MailSenderService mailSenderService; @@ -140,6 +143,8 @@ private String processRow(CSVRecord record, int rowNumber, if (fatherLastName.isEmpty()) rowErrors.add("father last name is empty"); if (motherLastName.isEmpty()) rowErrors.add("mother last name is empty"); if (enrollmentNumber.isEmpty()) rowErrors.add("enrollment number is empty"); + if (!enrollmentNumber.isEmpty() && !ENROLLMENT_PATTERN.matcher(enrollmentNumber).matches()) + rowErrors.add("enrollment number is invalid (must be 10 digits: year >=1994, then 630, then 3 digits)"); if (email.isEmpty()) rowErrors.add("email is empty"); if (!email.isEmpty() && !EMAIL_PATTERN.matcher(email).matches()) rowErrors.add("email is invalid"); diff --git a/codehive-backend/src/main/java/com/github/codehive/service/ExecutionRequestService.java b/codehive-backend/src/main/java/com/github/codehive/service/ExecutionRequestService.java new file mode 100644 index 0000000..2723b20 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/service/ExecutionRequestService.java @@ -0,0 +1,171 @@ +package com.github.codehive.service; + +import java.io.InputStream; +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.codehive.messaging.producer.ExecutionRequestProducer; +import com.github.codehive.model.dto.ExecutionDTO; +import com.github.codehive.model.dto.queue.ExecutionJob; +import com.github.codehive.model.dto.queue.ExecutionReport; +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.Execution; +import com.github.codehive.model.entity.ReferenceSolution; +import com.github.codehive.model.entity.User; +import com.github.codehive.model.enums.ExecutionType; +import com.github.codehive.model.exception.EntityNotFoundException; +import com.github.codehive.model.mapper.ExecutionMapper; +import com.github.codehive.model.request.execution.ExecutionRequest; +import com.github.codehive.repository.AssignmentRepository; +import com.github.codehive.repository.ExecutionRepository; +import com.github.codehive.repository.ReferenceSolutionRepository; +import com.github.codehive.repository.TestCaseRepository; +import com.github.codehive.repository.UserRepository; +import com.github.codehive.utils.FileExtensionUtil; +import com.github.codehive.utils.ObjectKeyBuilder; + +@Service +public class ExecutionRequestService { + + private final ExecutionRequestProducer executionRequestProducer; + private final ExecutionRepository executionRepository; + private final ObjectStorageService objectStorageService; + private final UserRepository userRepository; + private final AssignmentRepository assignmentRepository; + private final ReferenceSolutionRepository referenceSolutionRepository; + private final TestCaseRepository testCaseRepository; + private final ObjectMapper objectMapper; + + public ExecutionRequestService(ExecutionRequestProducer executionRequestProducer, + ExecutionRepository executionRepository, + ObjectStorageService objectStorageService, + UserRepository userRepository, + AssignmentRepository assignmentRepository, + ReferenceSolutionRepository referenceSolutionRepository, + TestCaseRepository testCaseRepository, + ObjectMapper objectMapper) { + this.executionRequestProducer = executionRequestProducer; + this.executionRepository = executionRepository; + this.objectStorageService = objectStorageService; + this.userRepository = userRepository; + this.assignmentRepository = assignmentRepository; + this.referenceSolutionRepository = referenceSolutionRepository; + this.testCaseRepository = testCaseRepository; + this.objectMapper = objectMapper; + } + + @Transactional + public ExecutionDTO requestExecution(ExecutionRequest request) { + Assignment assignment = assignmentRepository.findById(request.getAssignmentId()) + .orElseThrow(() -> new EntityNotFoundException( + "Assignment not found with id: " + request.getAssignmentId())); + + Execution execution = new Execution(request.getExecutionType()); + + if (request.getRequesterId() != null) { + User user = userRepository.findById(request.getRequesterId()) + .orElseThrow(() -> new EntityNotFoundException( + "User not found with id: " + request.getRequesterId())); + execution.setUser(user); + } + + execution = executionRepository.save(execution); + + String extension = FileExtensionUtil.getFileExtensionByLanguage(request.getLanguage()); + String codeStorageKey = ObjectKeyBuilder.executionSourceCode(execution.getId(), extension); + + try { + objectStorageService.upload(codeStorageKey, request.getCode()); + } catch (Exception e) { + throw new RuntimeException("Failed to upload source code to object storage", e); + } + + ExecutionJob job = buildExecutionJob(execution, request, assignment, extension); + executionRequestProducer.sendExecutionRequest(job); + + return ExecutionMapper.toDTO(execution); + } + + @Transactional(readOnly = true) + public ExecutionDTO getExecutionById(UUID id) { + Execution execution = executionRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Execution not found with id: " + id)); + return ExecutionMapper.toDTO(execution); + } + + @Transactional(readOnly = true) + public ExecutionReport getExecutionReport(UUID id) { + Execution execution = executionRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Execution not found with id: " + id)); + + String reportKey = ObjectKeyBuilder.executionReport(id); + try { + InputStream reportStream = objectStorageService.download(reportKey); + return objectMapper.readValue(reportStream, ExecutionReport.class); + } catch (Exception e) { + if (execution.getStatus() != null && execution.getStatus().name().equals("PENDING")) { + throw new EntityNotFoundException("Report not available yet: execution " + id + " is still pending"); + } + throw new EntityNotFoundException("Report not found for execution: " + id); + } + } + + private ExecutionJob buildExecutionJob(Execution execution, ExecutionRequest request, + Assignment assignment, String extension) { + String sourceKey = ObjectKeyBuilder.executionSourceCode(execution.getId(), extension); + String outputKey = ObjectKeyBuilder.executionTestCaseOutput(execution.getId()); + + if (request.getExecutionType() == ExecutionType.PRACTICE) { + ReferenceSolution referenceSolution = resolveReferenceSolution(assignment); + String refExtension = FileExtensionUtil.getFileExtensionByLanguage(referenceSolution.getLanguage()); + String refPath = ObjectKeyBuilder.referenceSolutionSourceCode(assignment.getId(), refExtension); + + List testCases = request.getTestCases(); + return new ExecutionJob( + execution.getId(), + sourceKey, + refPath, + request.getLanguage(), + request.getExecutionType(), + testCases, + assignment.getTimeLimitMs(), + assignment.getMemoryLimitMb(), + assignment.getComparatorType(), + outputKey, + testCases != null ? testCases.size() : 0, + ObjectKeyBuilder.testsPath(assignment.getId()), + referenceSolution.getLanguage() + ); + } else { + long numTests = testCaseRepository.countByAssignmentId(assignment.getId()); + return new ExecutionJob( + execution.getId(), + sourceKey, + null, + request.getLanguage(), + request.getExecutionType(), + null, + assignment.getTimeLimitMs(), + assignment.getMemoryLimitMb(), + assignment.getComparatorType(), + outputKey, + (int) numTests, + ObjectKeyBuilder.testsPath(assignment.getId()), + null + ); + } + } + + private ReferenceSolution resolveReferenceSolution(Assignment assignment) { + List solutions = referenceSolutionRepository.findByAssignmentId(assignment.getId()); + if (solutions.isEmpty()) { + throw new EntityNotFoundException( + "No reference solution found for assignment: " + assignment.getId()); + } + return solutions.get(0); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/service/ExecutionResultService.java b/codehive-backend/src/main/java/com/github/codehive/service/ExecutionResultService.java new file mode 100644 index 0000000..0bb1f23 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/service/ExecutionResultService.java @@ -0,0 +1,61 @@ +package com.github.codehive.service; + +import com.github.codehive.model.dto.queue.ExecutionReport; +import com.github.codehive.model.entity.Execution; +import com.github.codehive.repository.ExecutionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ExecutionResultService { + private static final Logger logger = LoggerFactory.getLogger(ExecutionResultService.class); + + private final ExecutionRepository executionRepository; + + public ExecutionResultService(ExecutionRepository executionRepository) { + this.executionRepository = executionRepository; + } + + @Transactional + public void processExecutionResult(ExecutionReport report) { + logger.info("[WORKFLOW] DB UPDATE: Processing execution result - executionId={}, status={}", + report.getExecutionId(), report.getOverallStatus()); + + Execution execution = executionRepository.findById(report.getExecutionId()) + .orElse(null); + + if (execution == null) { + logger.warn("[WORKFLOW] DB UPDATE SKIPPED: Execution not found in database - executionId={}", + report.getExecutionId()); + return; + } + + logger.info("[WORKFLOW] DB UPDATE: Found execution in database - executionId={}, currentStatus={}", + execution.getId(), execution.getStatus()); + + // Update execution status + execution.setStatus(report.getOverallStatus()); + + // Update timing (use max execution time as representative) + if (report.getMaxExecutionTimeMs() != null) { + execution.setTimeMs(report.getMaxExecutionTimeMs()); + } + + // Update memory (convert KB to MB, use max memory) + if (report.getMaxMemoryUsedMb() != null) { + execution.setMemoryMb(report.getMaxMemoryUsedMb()); + } + + executionRepository.save(execution); + + logger.info("[WORKFLOW] DB UPDATE: Execution updated successfully - executionId={}, newStatus={}, timeMs={}, memoryMb={}, passed={}/{}", + execution.getId(), + execution.getStatus(), + execution.getTimeMs(), + execution.getMemoryMb(), + report.getPassedTests(), + report.getTotalTests()); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/service/ObjectStorageService.java b/codehive-backend/src/main/java/com/github/codehive/service/ObjectStorageService.java new file mode 100644 index 0000000..b6fda14 --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/service/ObjectStorageService.java @@ -0,0 +1,58 @@ +package com.github.codehive.service; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import io.minio.MinioClient; +import io.minio.PutObjectArgs; + +@Service +public class ObjectStorageService { + private final MinioClient minioClient; + + @Value("${minio.bucketName}") + private String bucketName; + + public ObjectStorageService(MinioClient minioClient) { + this.minioClient = minioClient; + } + + public void upload(String objectKey, InputStream data, long size, String contentType) throws Exception { + minioClient.putObject( + io.minio.PutObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .stream(data, size, -1) + .contentType(contentType) + .build() + ); + } + + public void upload(String objectKey, String content) throws Exception { + byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); + ByteArrayInputStream stream = new ByteArrayInputStream(contentBytes); + + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .stream(stream, contentBytes.length, -1) + .contentType("text/plain") + .build() + ); + } + + + public InputStream download(String objectKey) throws Exception { + return minioClient.getObject( + io.minio.GetObjectArgs.builder() + .bucket(bucketName) + .object(objectKey) + .build() + ); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/utils/FileExtensionUtil.java b/codehive-backend/src/main/java/com/github/codehive/utils/FileExtensionUtil.java new file mode 100644 index 0000000..dc61b4e --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/utils/FileExtensionUtil.java @@ -0,0 +1,20 @@ +package com.github.codehive.utils; + +import com.github.codehive.model.enums.Language; + +public class FileExtensionUtil { + public static String getFileExtensionByLanguage(Language language) { + switch (language) { + case JAVA: + return "java"; + case PYTHON: + return "py"; + case CPP: + return "cpp"; + case C: + return "c"; + default: + throw new IllegalArgumentException("Unsupported language: " + language); + } + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/utils/JwtUtil.java b/codehive-backend/src/main/java/com/github/codehive/utils/JwtUtil.java index a458175..8d8d4fc 100644 --- a/codehive-backend/src/main/java/com/github/codehive/utils/JwtUtil.java +++ b/codehive-backend/src/main/java/com/github/codehive/utils/JwtUtil.java @@ -41,17 +41,25 @@ private SecretKey getSignInKey() { return Keys.hmacShaKeyFor(keyBytes); } - public T extractClaim(String token, Function claimsResolver){ - final Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); + public T extractClaim(String token, Function claimsResolver) { + try { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } catch (ExpiredJwtException e) { + throw e; + } } private Claims extractAllClaims(String token) { - return Jwts.parser() - .verifyWith(getSignInKey()) - .build() - .parseSignedClaims(token) - .getPayload(); + try { + return Jwts.parser() + .verifyWith(getSignInKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + throw e; + } } public boolean isTokenValid(String token, String email){ diff --git a/codehive-backend/src/main/java/com/github/codehive/utils/ObjectKeyBuilder.java b/codehive-backend/src/main/java/com/github/codehive/utils/ObjectKeyBuilder.java new file mode 100644 index 0000000..7b5bbdb --- /dev/null +++ b/codehive-backend/src/main/java/com/github/codehive/utils/ObjectKeyBuilder.java @@ -0,0 +1,41 @@ +package com.github.codehive.utils; + +import java.util.UUID; + +public class ObjectKeyBuilder { + public static String testCaseInput(UUID assignmentId, UUID testCaseId) { + return String.format("test-suites/assignments/%s/tc-%s/tc%s.in", assignmentId, testCaseId, testCaseId); + } + + public static String testCaseOutput(UUID assignmentId, UUID testCaseId) { + return String.format("test-suites/assignments/%s/tc-%s/tc%s.out", assignmentId, testCaseId, testCaseId); + } + + public static String testsPath(UUID assignmentId) { + return String.format("test-suites/assignments/%s/", assignmentId); + } + + public static String submissionSourceCode(UUID assignmentId, UUID submissionId, String fileExtension) { + return String.format("submissions/assignments/%s/submission-%s/Main.%s", assignmentId, submissionId, fileExtension); + } + + public static String executionOutput(UUID submissionId, UUID executionId, String fileExtension) { + return String.format("executions/assignments/%s/execution-%s/output.%s", submissionId, executionId, fileExtension); + } + + public static String referenceSolutionSourceCode(UUID assignmentId, String fileExtension) { + return String.format("test-suites/assignments/%s/reference/Main.%s", assignmentId, fileExtension); + } + + public static String executionTestCaseOutput(UUID executionId) { + return String.format("test-execution/execution-%s/output/", executionId); + } + + public static String executionSourceCode(UUID executionId, String fileExtension) { + return String.format("test-execution/execution-%s/source.%s", executionId, fileExtension); + } + + public static String executionReport(UUID executionId) { + return String.format("test-execution/execution-%s/output/report.json", executionId); + } +} diff --git a/codehive-backend/src/main/java/com/github/codehive/websocket/CsvProgressWebSocketHandler.java b/codehive-backend/src/main/java/com/github/codehive/websocket/CsvProgressWebSocketHandler.java index 5eef471..8b66dad 100644 --- a/codehive-backend/src/main/java/com/github/codehive/websocket/CsvProgressWebSocketHandler.java +++ b/codehive-backend/src/main/java/com/github/codehive/websocket/CsvProgressWebSocketHandler.java @@ -9,6 +9,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; @@ -38,7 +39,7 @@ public CsvProgressWebSocketHandler(ObjectMapper objectMapper) { } @Override - protected void handleTextMessage(WebSocketSession session, TextMessage message) { + protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) { String taskId = message.getPayload().trim(); session.getAttributes().put(TASK_ID_ATTRIBUTE, taskId); taskSessions.put(taskId, session); @@ -58,7 +59,7 @@ public void queueTask(String taskId, Runnable task) { } @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { String taskId = (String) session.getAttributes().get(TASK_ID_ATTRIBUTE); if (taskId != null) { taskSessions.remove(taskId); diff --git a/codehive-backend/src/main/resources/application.properties b/codehive-backend/src/main/resources/application.properties index 808b485..c7ed008 100644 --- a/codehive-backend/src/main/resources/application.properties +++ b/codehive-backend/src/main/resources/application.properties @@ -2,9 +2,9 @@ spring.config.import=optional:file:.env[.properties] spring.application.name=codehive -spring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/codehive} -spring.datasource.username=${DATABASE_USERNAME:codehive} -spring.datasource.password=${DATABASE_PASSWORD:codehive} +spring.datasource.url=${DATABASE_URL:dburlexample} +spring.datasource.username=${DATABASE_USERNAME:default} +spring.datasource.password=${DATABASE_PASSWORD:default} spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=update @@ -16,23 +16,37 @@ server.port=8080 # JWT Configuration # The secret must be a Base64-encoded key of at least 256 bits (32 bytes) for HS256 -security.jwt.secret=${JWT_SECRET:Y29kZWhpdmVTZWNyZXRLZXlGb3JKd3RTaWduaW5nMjU2Qml0c09r} -security.jwt.expiration=${JWT_EXPIRATION:36000000} +security.jwt.secret=${JWT_SECRET:defaultSecret} +security.jwt.expiration=${JWT_EXPIRATION:86400000} # Java Mail Configuration -spring.mail.host=${MAIL_HOST:smtp.gmail.com} +spring.mail.host=${MAIL_HOST:defaultHost} spring.mail.port=${MAIL_PORT:587} -spring.mail.username=${MAIL_USERNAME:codehive@codehive.com} -spring.mail.password=${MAIL_PASSWORD:codehive} +spring.mail.username=${MAIL_USERNAME:defaultUser} +spring.mail.password=${MAIL_PASSWORD:defaultPassword} spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:true} spring.mail.properties.mail.smtp.starttls.enable=${MAIL_STARTTLS_ENABLE:true} # Frontend URL -frontend.url=${FRONTEND_URL:http://localhost:5173} - -# Default Super Admin Configuration +frontend.url=${FRONTEND_URL:defaultFrontendUrl} + +# Minio Configuration +minio.url=${MINIO_URL:defaultMinioUrl} +minio.accessKey=${MINIO_ACCESS_KEY:defaultAccessKey} +minio.secretKey=${MINIO_SECRET_KEY:defaultSecretKey} +minio.bucketName=${MINIO_BUCKET_NAME:defaultBucketName} + +# RabbitMQ Configuration +spring.rabbitmq.host=${RABBITMQ_HOST:defaultRabbitHost} +spring.rabbitmq.port=${RABBITMQ_PORT:5672} +spring.rabbitmq.username=${RABBITMQ_USERNAME:defaultRabbitUser} +spring.rabbitmq.password=${RABBITMQ_PASSWORD:defaultRabbitPassword} +spring.rabbitmq.queue=${RABBITMQ_QUEUE:defaultQueue} +spring.rabbitmq.result.queue=${RABBITMQ_RESULT_QUEUE:defaultResultQueue} + +# Admin Initializer app.admin.email=${ADMIN_EMAIL:admin@codehive.com} -app.admin.password=${ADMIN_PASSWORD} +app.admin.password=${ADMIN_PASSWORD:Admin@12345} app.admin.name=${ADMIN_NAME:Super} app.admin.lastName=${ADMIN_LAST_NAME:Admin} -app.admin.enrollmentNumber=${ADMIN_ENROLLMENT:ADMIN-001} \ No newline at end of file +app.admin.enrollmentNumber=${ADMIN_ENROLLMENT_NUMBER:ADMIN-001} \ No newline at end of file diff --git a/codehive-backend/src/test/java/com/github/codehive/controller/AssignmentControllerIntegrationTest.java b/codehive-backend/src/test/java/com/github/codehive/controller/AssignmentControllerIntegrationTest.java new file mode 100644 index 0000000..fe87a22 --- /dev/null +++ b/codehive-backend/src/test/java/com/github/codehive/controller/AssignmentControllerIntegrationTest.java @@ -0,0 +1,261 @@ +package com.github.codehive.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.codehive.config.TestAsyncConfig; +import com.github.codehive.messaging.producer.TestGenerationRequestProducer; +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.Language; +import com.github.codehive.model.enums.Role; +import com.github.codehive.model.entity.User; +import com.github.codehive.model.request.assignment.CreateAssignmentRequest; +import com.github.codehive.repository.AssignmentRepository; +import com.github.codehive.repository.UserRepository; +import com.github.codehive.service.ObjectStorageService; +import com.github.codehive.utils.JwtUtil; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +@Import(TestAsyncConfig.class) +@DisplayName("AssignmentController Integration") +class AssignmentControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private AssignmentRepository assignmentRepository; + @Autowired private UserRepository userRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private JwtUtil jwtUtil; + + @MockitoBean private ObjectStorageService objectStorageService; + @MockitoBean private TestGenerationRequestProducer testGenerationRequestProducer; + + private String teacherToken; + private String studentToken; + + @BeforeEach + void setUp() throws Exception { + userRepository.deleteAll(); + userRepository.flush(); + + User teacher = new User(); + teacher.setEmail("teacher@test.com"); + teacher.setPassword(passwordEncoder.encode("Pass123!")); + teacher.setName("Teacher"); + teacher.setLastName("Last"); + teacher.setEnrollmentNumber("TEACHER-001"); + teacher.setRole(Role.TEACHER); + teacher.setIsActive(true); + userRepository.save(teacher); + teacherToken = jwtUtil.generateToken(Map.of("role", "TEACHER"), "teacher@test.com"); + + User student = new User(); + student.setEmail("student@test.com"); + student.setPassword(passwordEncoder.encode("Pass123!")); + student.setName("Student"); + student.setLastName("Last"); + student.setEnrollmentNumber("STUDENT-001"); + student.setRole(Role.STUDENT); + student.setIsActive(true); + userRepository.save(student); + studentToken = jwtUtil.generateToken(Map.of("role", "STUDENT"), "student@test.com"); + + doNothing().when(objectStorageService).upload(anyString(), anyString()); + doNothing().when(testGenerationRequestProducer).sendTestGenerationRequest(any()); + } + + // ── LIST ──────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /api/assignments") + class ListAssignments { + + @Test + @DisplayName("returns 200 with empty page when no assignments exist") + void emptyList() throws Exception { + mockMvc.perform(get("/api/assignments") + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isArray()) + .andExpect(jsonPath("$.data.totalElements").value(0)); + } + + @Test + @DisplayName("returns assignments ordered by createdAt desc") + void returnsSavedAssignments() throws Exception { + saveAssignment("Alpha"); + saveAssignment("Beta"); + + mockMvc.perform(get("/api/assignments") + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.content[0].title").value("Beta")); + } + + @Test + @DisplayName("respects page and size query params") + void pagination() throws Exception { + saveAssignment("A"); + saveAssignment("B"); + saveAssignment("C"); + + mockMvc.perform(get("/api/assignments").param("page", "1").param("size", "2") + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.totalPages").value(2)); + } + } + + // ── GET BY ID ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /api/assignments/{id}") + class GetAssignmentById { + + @Test + @DisplayName("returns 200 with assignment data") + void found() throws Exception { + Assignment a = saveAssignment("FindMe"); + + mockMvc.perform(get("/api/assignments/{id}", a.getId()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.title").value("FindMe")); + } + + @Test + @DisplayName("returns 404 for non-existent ID") + void notFound() throws Exception { + mockMvc.perform(get("/api/assignments/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isNotFound()); + } + } + + // ── CREATE ─────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /api/assignments") + class CreateAssignment { + + @Test + @DisplayName("returns 401 when unauthenticated") + void unauthenticated() throws Exception { + mockMvc.perform(multipart("/api/assignments") + .file(metadataPart()) + .file(referenceSolutionPart()) + .file(testCaseInputPart())) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("returns 403 when authenticated as STUDENT") + void forbiddenForStudent() throws Exception { + mockMvc.perform(multipart("/api/assignments") + .file(metadataPart()) + .file(referenceSolutionPart()) + .file(testCaseInputPart()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("returns 202 and queues job when authenticated as TEACHER") + void teacherCanCreate() throws Exception { + mockMvc.perform(multipart("/api/assignments") + .file(metadataPart()) + .file(referenceSolutionPart()) + .file(testCaseInputPart()) + .header("Authorization", "Bearer " + teacherToken)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.title").value("Test Assignment")); + } + + @Test + @DisplayName("returns 400 when metadata is missing required fields") + void missingRequiredFields() throws Exception { + MockMultipartFile badMetadata = new MockMultipartFile( + "metadata", "", "application/json", + """ + {"title":""} + """.getBytes() + ); + + mockMvc.perform(multipart("/api/assignments") + .file(badMetadata) + .file(referenceSolutionPart()) + .file(testCaseInputPart()) + .header("Authorization", "Bearer " + teacherToken)) + .andExpect(status().isBadRequest()); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Assignment saveAssignment(String title) { + Assignment a = new Assignment(title, "desc", 5000L, 256L, ComparatorType.EXACT_MATCH); + a.setAllowedLanguages(List.of(Language.JAVA)); + a.setIsActive(true); + return assignmentRepository.saveAndFlush(a); + } + + private MockMultipartFile metadataPart() throws Exception { + CreateAssignmentRequest req = new CreateAssignmentRequest(); + req.setTitle("Test Assignment"); + req.setDescription("A test assignment"); + req.setTimeLimitMs(5000L); + req.setMemoryLimitMb(256L); + req.setComparatorType(ComparatorType.EXACT_MATCH); + req.setAllowedLanguages(List.of(Language.JAVA)); + req.setReferenceLanguage(Language.JAVA); + + return new MockMultipartFile( + "metadata", "", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(req) + ); + } + + private MockMultipartFile referenceSolutionPart() { + return new MockMultipartFile( + "referenceSolution", "Main.java", "text/plain", + "public class Main { public static void main(String[] a) {} }".getBytes() + ); + } + + private MockMultipartFile testCaseInputPart() { + return new MockMultipartFile( + "testCaseInputs", "tc1.txt", "text/plain", + "3 5".getBytes() + ); + } +} diff --git a/codehive-backend/src/test/java/com/github/codehive/controller/AuthControllerIntegrationTest.java b/codehive-backend/src/test/java/com/github/codehive/controller/AuthControllerIntegrationTest.java index bcc8a2d..52270bc 100644 --- a/codehive-backend/src/test/java/com/github/codehive/controller/AuthControllerIntegrationTest.java +++ b/codehive-backend/src/test/java/com/github/codehive/controller/AuthControllerIntegrationTest.java @@ -82,7 +82,7 @@ void setUp() { testUser.setName("Existing"); testUser.setLastName("User"); testUser.setPassword(passwordEncoder.encode("password123")); - testUser.setEnrollmentNumber("ENR001"); + testUser.setEnrollmentNumber("1994630001"); testUser.setRole(Role.STUDENT); testUser.setIsActive(true); testUser.setTemporaryPassword(false); @@ -94,7 +94,7 @@ void setUp() { adminUser.setName("Admin"); adminUser.setLastName("User"); adminUser.setPassword(passwordEncoder.encode("admin123")); - adminUser.setEnrollmentNumber("ADM001"); + adminUser.setEnrollmentNumber("1994630002"); adminUser.setRole(Role.ADMIN); adminUser.setIsActive(true); adminUser.setTemporaryPassword(false); @@ -139,7 +139,7 @@ void login_WithValidCredentialsUsingEmail_ReturnsTokenAndUserData() throws Excep void login_WithValidCredentialsUsingEnrollmentNumber_ReturnsTokenAndUserData() throws Exception { // Given LoginRequest loginRequest = new LoginRequest(); - loginRequest.setIdentifier("ENR001"); + loginRequest.setIdentifier("1994630001"); loginRequest.setPassword("password123"); // When/Then @@ -260,7 +260,7 @@ void signup_WithAdminAndValidData_ReturnsCreated() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -273,7 +273,7 @@ void signup_WithAdminAndValidData_ReturnsCreated() throws Exception { .andExpect(jsonPath("$.data.email").value("newuser@example.com")) .andExpect(jsonPath("$.data.name").value("New")) .andExpect(jsonPath("$.data.lastName").value("User Test")) - .andExpect(jsonPath("$.data.enrollmentNumber").value("ENR002")) + .andExpect(jsonPath("$.data.enrollmentNumber").value("2023630001")) .andExpect(jsonPath("$.data.role").value("STUDENT")) .andExpect(jsonPath("$.data.isActive").value(true)) .andExpect(jsonPath("$.data.temporaryPassword").value(true)); @@ -293,7 +293,7 @@ void signup_WithNonAdmin_ReturnsForbidden() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -313,7 +313,7 @@ void signup_WithoutAuthentication_ReturnsForbidden() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -332,7 +332,7 @@ void signup_WithExistingEmail_ReturnsConflict() throws Exception { signUpRequest.setName("Another"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR999"); + signUpRequest.setEnrollmentNumber("2020630999"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -353,7 +353,7 @@ void signup_WithExistingEnrollmentNumber_ReturnsConflict() throws Exception { signUpRequest.setName("Another"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR001"); + signUpRequest.setEnrollmentNumber("1994630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -374,7 +374,7 @@ void signup_WithInvalidEmailFormat_ReturnsBadRequest() throws Exception { signUpRequest.setName("Test"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR003"); + signUpRequest.setEnrollmentNumber("2021630003"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -428,7 +428,7 @@ void signup_WithValidData_StoresEncodedPassword() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR003"); + signUpRequest.setEnrollmentNumber("2021630003"); signUpRequest.setRole(Role.STUDENT); // When @@ -453,7 +453,7 @@ void signup_WithTeacherRole_RegistersTeacher() throws Exception { signUpRequest.setName("Teacher"); signUpRequest.setFatherLastName("Last"); signUpRequest.setMotherLastName("Name"); - signUpRequest.setEnrollmentNumber("T001"); + signUpRequest.setEnrollmentNumber("2022630001"); signUpRequest.setRole(Role.TEACHER); // When/Then @@ -587,7 +587,7 @@ void signup_WithWrongContentType_ReturnsUnsupportedMediaType() throws Exception signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -627,7 +627,7 @@ void signup_WithoutAuthentication_ReturnsForbidden() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then @@ -662,7 +662,7 @@ void signup_SuccessfulSignup_DoesNotExposePassword() throws Exception { signUpRequest.setName("New"); signUpRequest.setFatherLastName("User"); signUpRequest.setMotherLastName("Test"); - signUpRequest.setEnrollmentNumber("ENR002"); + signUpRequest.setEnrollmentNumber("2023630001"); signUpRequest.setRole(Role.STUDENT); // When/Then diff --git a/codehive-backend/src/test/java/com/github/codehive/controller/CheckExecutionControllerIntegrationTest.java b/codehive-backend/src/test/java/com/github/codehive/controller/CheckExecutionControllerIntegrationTest.java new file mode 100644 index 0000000..fc91b87 --- /dev/null +++ b/codehive-backend/src/test/java/com/github/codehive/controller/CheckExecutionControllerIntegrationTest.java @@ -0,0 +1,265 @@ +package com.github.codehive.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.codehive.config.TestAsyncConfig; +import com.github.codehive.messaging.producer.ExecutionRequestProducer; +import com.github.codehive.model.dto.queue.ExecutionReport; +import com.github.codehive.model.entity.Assignment; +import com.github.codehive.model.entity.Execution; +import com.github.codehive.model.enums.ComparatorType; +import com.github.codehive.model.enums.ExecutionStatus; +import com.github.codehive.model.enums.ExecutionType; +import com.github.codehive.model.enums.Language; +import com.github.codehive.model.enums.Role; +import com.github.codehive.model.entity.User; +import com.github.codehive.model.request.execution.ExecutionRequest; +import com.github.codehive.repository.AssignmentRepository; +import com.github.codehive.repository.ExecutionRepository; +import com.github.codehive.model.entity.ReferenceSolution; +import com.github.codehive.repository.ReferenceSolutionRepository; +import com.github.codehive.repository.TestCaseRepository; +import com.github.codehive.repository.UserRepository; +import com.github.codehive.service.ObjectStorageService; +import com.github.codehive.utils.JwtUtil; +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +@Import(TestAsyncConfig.class) +@DisplayName("CheckExecutionController Integration") +class CheckExecutionControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private AssignmentRepository assignmentRepository; + @Autowired private ExecutionRepository executionRepository; + @Autowired private ReferenceSolutionRepository referenceSolutionRepository; + @Autowired private TestCaseRepository testCaseRepository; + @Autowired private UserRepository userRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private JwtUtil jwtUtil; + + @MockitoBean private ObjectStorageService objectStorageService; + @MockitoBean private ExecutionRequestProducer executionRequestProducer; + + private Assignment assignment; + private String studentToken; + + @BeforeEach + void setUp() throws Exception { + userRepository.deleteAll(); + userRepository.flush(); + + User student = new User(); + student.setEmail("student@test.com"); + student.setPassword(passwordEncoder.encode("Pass123!")); + student.setName("Student"); + student.setLastName("Last"); + student.setEnrollmentNumber("STU-001"); + student.setRole(Role.STUDENT); + student.setIsActive(true); + userRepository.save(student); + studentToken = jwtUtil.generateToken(Map.of("role", "STUDENT"), "student@test.com"); + + assignment = new Assignment("Sum Two Numbers", "Add a+b", 5000L, 256L, ComparatorType.EXACT_MATCH); + assignment.setAllowedLanguages(List.of(Language.JAVA, Language.PYTHON)); + assignment.setIsActive(true); + assignmentRepository.saveAndFlush(assignment); + + ReferenceSolution ref = new ReferenceSolution(assignment, Language.PYTHON); + referenceSolutionRepository.saveAndFlush(ref); + + doNothing().when(objectStorageService).upload(anyString(), anyString()); + doNothing().when(executionRequestProducer).sendExecutionRequest(any()); + } + + // ── SUBMIT ─────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("POST /api/execution/check") + class SubmitExecution { + + @Test + @DisplayName("returns 202 with execution ID for valid PRACTICE request") + void acceptsValidRequest() throws Exception { + ExecutionRequest req = new ExecutionRequest( + "print(int(input()))", Language.PYTHON, + null, assignment.getId(), + List.of("3 5"), ExecutionType.PRACTICE + ); + + mockMvc.perform(post("/api/execution/check") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.id").exists()) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + @DisplayName("returns 400 when code is blank") + void rejectsBlankCode() throws Exception { + ExecutionRequest req = new ExecutionRequest( + "", Language.PYTHON, + null, assignment.getId(), + List.of("3 5"), ExecutionType.PRACTICE + ); + + mockMvc.perform(post("/api/execution/check") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 400 when language is missing") + void rejectsMissingLanguage() throws Exception { + String body = """ + {"code":"print(1)","executionType":"PRACTICE","assignmentId":"%s"} + """.formatted(assignment.getId()); + + mockMvc.perform(post("/api/execution/check") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("returns 404 when assignment does not exist") + void notFoundAssignment() throws Exception { + ExecutionRequest req = new ExecutionRequest( + "print(1)", Language.PYTHON, + null, UUID.randomUUID(), + List.of("1"), ExecutionType.PRACTICE + ); + + mockMvc.perform(post("/api/execution/check") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isNotFound()); + } + } + + // ── GET STATUS ─────────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /api/execution/check/{id}") + class GetExecution { + + @Test + @DisplayName("returns 200 with execution status") + void found() throws Exception { + Execution exec = new Execution(ExecutionType.PRACTICE); + exec.setStatus(ExecutionStatus.PENDING); + executionRepository.saveAndFlush(exec); + + mockMvc.perform(get("/api/execution/check/{id}", exec.getId()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(exec.getId().toString())) + .andExpect(jsonPath("$.data.status").value("PENDING")); + } + + @Test + @DisplayName("returns 404 for non-existent execution ID") + void notFound() throws Exception { + mockMvc.perform(get("/api/execution/check/{id}", UUID.randomUUID()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isNotFound()); + } + } + + // ── GET REPORT ─────────────────────────────────────────────────────────── + + @Nested + @DisplayName("GET /api/execution/check/{id}/report") + class GetReport { + + @Test + @DisplayName("returns 200 with parsed report from object storage") + void returnsReport() throws Exception { + Execution exec = new Execution(ExecutionType.PRACTICE); + exec.setStatus(ExecutionStatus.AC); + executionRepository.saveAndFlush(exec); + + String reportJson = """ + { + "executionId":"%s", + "overallStatus":"AC", + "testCaseResults":[], + "totalTests":1, + "passedTests":1, + "failedTests":0, + "totalExecutionTimeMs":120, + "maxExecutionTimeMs":120, + "maxMemoryUsedMb":10, + "compilationError":null + } + """.formatted(exec.getId()); + + when(objectStorageService.download(anyString())) + .thenReturn(new ByteArrayInputStream(reportJson.getBytes())); + + mockMvc.perform(get("/api/execution/check/{id}/report", exec.getId()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.overallStatus").value("AC")) + .andExpect(jsonPath("$.data.passedTests").value(1)); + } + + @Test + @DisplayName("returns 404 when report not found for non-existent execution") + void notFoundExecution() throws Exception { + mockMvc.perform(get("/api/execution/check/{id}/report", UUID.randomUUID()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("returns 404 when execution is PENDING and report not uploaded yet") + void reportNotYetAvailable() throws Exception { + Execution exec = new Execution(ExecutionType.PRACTICE); + exec.setStatus(ExecutionStatus.PENDING); + executionRepository.saveAndFlush(exec); + + when(objectStorageService.download(anyString())) + .thenThrow(new RuntimeException("object not found")); + + mockMvc.perform(get("/api/execution/check/{id}/report", exec.getId()) + .header("Authorization", "Bearer " + studentToken)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/codehive-backend/src/test/java/com/github/codehive/model/mapper/UserMapperTest.java b/codehive-backend/src/test/java/com/github/codehive/model/mapper/UserMapperTest.java index 275ff03..23a3248 100644 --- a/codehive-backend/src/test/java/com/github/codehive/model/mapper/UserMapperTest.java +++ b/codehive-backend/src/test/java/com/github/codehive/model/mapper/UserMapperTest.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,6 +19,13 @@ @DisplayName("UserMapper Unit") class UserMapperTest { + private static final UUID USER_ID_1 = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID_2 = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID USER_ID_A = UUID.fromString("00000000-0000-0000-0000-000000000010"); + private static final UUID USER_ID_B = UUID.fromString("00000000-0000-0000-0000-000000000020"); + private static final UUID MINIMAL_ID = UUID.fromString("00000000-0000-0000-0000-000000000999"); + private static final UUID DTO_MIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000888"); + private User testUser; private UserDTO testUserDTO; private LocalDateTime testDate; @@ -28,7 +36,7 @@ void setUp() { // Setup test user entity testUser = new User(); - testUser.setId(1L); + testUser.setId(USER_ID_1); testUser.setEmail("test@example.com"); testUser.setName("John"); testUser.setLastName("Doe"); @@ -41,7 +49,7 @@ void setUp() { // Setup test user DTO testUserDTO = new UserDTO(); - testUserDTO.setId(2L); + testUserDTO.setId(USER_ID_2); testUserDTO.setEmail("jane@example.com"); testUserDTO.setName("Jane"); testUserDTO.setLastName("Smith"); @@ -90,7 +98,7 @@ void toDTO_WithNullUser_ReturnsNull() { void toDTO_WithMinimalUser_MapsAvailableFields() { // Given User minimalUser = new User(); - minimalUser.setId(999L); + minimalUser.setId(MINIMAL_ID); minimalUser.setEmail("minimal@example.com"); // When @@ -98,7 +106,7 @@ void toDTO_WithMinimalUser_MapsAvailableFields() { // Then assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(999L); + assertThat(result.getId()).isEqualTo(MINIMAL_ID); assertThat(result.getEmail()).isEqualTo("minimal@example.com"); assertThat(result.getName()).isNull(); assertThat(result.getLastName()).isNull(); @@ -176,7 +184,7 @@ void toEntity_WithNullDTO_ReturnsNull() { void toEntity_WithMinimalDTO_MapsAvailableFields() { // Given UserDTO minimalDTO = new UserDTO(); - minimalDTO.setId(888L); + minimalDTO.setId(DTO_MIN_ID); minimalDTO.setEmail("minimal@example.com"); // When @@ -184,7 +192,7 @@ void toEntity_WithMinimalDTO_MapsAvailableFields() { // Then assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(888L); + assertThat(result.getId()).isEqualTo(DTO_MIN_ID); assertThat(result.getEmail()).isEqualTo("minimal@example.com"); assertThat(result.getName()).isNull(); assertThat(result.getPassword()).isNull(); @@ -200,13 +208,13 @@ class ListMappingTests { void toDTOList_WithMultipleUsers_MapsAllCorrectly() { // Given User user1 = new User(); - user1.setId(1L); + user1.setId(USER_ID_1); user1.setEmail("user1@example.com"); user1.setName("User"); user1.setLastName("One"); - + User user2 = new User(); - user2.setId(2L); + user2.setId(USER_ID_2); user2.setEmail("user2@example.com"); user2.setName("User"); user2.setLastName("Two"); @@ -218,9 +226,9 @@ void toDTOList_WithMultipleUsers_MapsAllCorrectly() { // Then assertThat(result).hasSize(2); - assertThat(result.get(0).getId()).isEqualTo(1L); + assertThat(result.get(0).getId()).isEqualTo(USER_ID_1); assertThat(result.get(0).getEmail()).isEqualTo("user1@example.com"); - assertThat(result.get(1).getId()).isEqualTo(2L); + assertThat(result.get(1).getId()).isEqualTo(USER_ID_2); assertThat(result.get(1).getEmail()).isEqualTo("user2@example.com"); } @@ -239,11 +247,11 @@ void toDTOList_WithEmptyList_ReturnsEmptyList() { void toEntityList_WithMultipleDTOs_MapsAllCorrectly() { // Given UserDTO dto1 = new UserDTO(); - dto1.setId(10L); + dto1.setId(USER_ID_A); dto1.setEmail("dto1@example.com"); - + UserDTO dto2 = new UserDTO(); - dto2.setId(20L); + dto2.setId(USER_ID_B); dto2.setEmail("dto2@example.com"); List dtos = Arrays.asList(dto1, dto2); @@ -253,9 +261,9 @@ void toEntityList_WithMultipleDTOs_MapsAllCorrectly() { // Then assertThat(result).hasSize(2); - assertThat(result.get(0).getId()).isEqualTo(10L); + assertThat(result.get(0).getId()).isEqualTo(USER_ID_A); assertThat(result.get(0).getEmail()).isEqualTo("dto1@example.com"); - assertThat(result.get(1).getId()).isEqualTo(20L); + assertThat(result.get(1).getId()).isEqualTo(USER_ID_B); assertThat(result.get(1).getEmail()).isEqualTo("dto2@example.com"); } @@ -264,7 +272,7 @@ void toEntityList_WithMultipleDTOs_MapsAllCorrectly() { void toDTOList_WithNullValuesInList_SkipsNulls() { // Given User user1 = new User(); - user1.setId(1L); + user1.setId(USER_ID_1); user1.setEmail("user1@example.com"); List users = Arrays.asList(user1, null); diff --git a/codehive-backend/src/test/java/com/github/codehive/service/AuthServiceTest.java b/codehive-backend/src/test/java/com/github/codehive/service/AuthServiceTest.java index 6cebfee..2973d99 100644 --- a/codehive-backend/src/test/java/com/github/codehive/service/AuthServiceTest.java +++ b/codehive-backend/src/test/java/com/github/codehive/service/AuthServiceTest.java @@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -68,7 +69,7 @@ class AuthServiceTest { void setUp() { // Setup test user testUser = new User(); - testUser.setId(1L); + testUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000001")); testUser.setEmail("test@example.com"); testUser.setPassword("encodedPassword123"); testUser.setName("John"); @@ -162,7 +163,7 @@ void login_GeneratesTokenWithCorrectClaims() { when(passwordEncoder.matches(loginRequest.getPassword(), testUser.getPassword())).thenReturn(true); when(jwtUtil.generateToken(any(Map.class), anyString())).thenAnswer(invocation -> { Map claims = invocation.getArgument(0); - assertThat(claims).containsEntry("userId", 1L); + assertThat(claims).containsEntry("userId", testUser.getId()); assertThat(claims).containsEntry("role", "STUDENT"); return "jwt-token-123"; }); @@ -213,7 +214,7 @@ void register_WithValidData_ReturnsUserDTO() { when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setPassword("encodedPassword"); savedUser.setName(signUpRequest.getName()); @@ -285,7 +286,7 @@ void register_GeneratesAndEncodesRandomPassword() { when(passwordEncoder.encode(anyString())).thenReturn("encodedRandomPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); @@ -313,7 +314,7 @@ void register_SetsRoleFromRequest() { when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); @@ -343,7 +344,7 @@ void register_SetsUserAsActiveByDefault() { when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); @@ -374,7 +375,7 @@ void register_SetsTemporaryPasswordTrue() { when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); @@ -404,7 +405,7 @@ void register_SendsWelcomeEmailWithTemporaryPassword() { when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); @@ -432,7 +433,7 @@ void register_ConcatenatesLastNames() { ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); User savedUser = new User(); - savedUser.setId(2L); + savedUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); savedUser.setEmail(signUpRequest.getEmail()); savedUser.setName(signUpRequest.getName()); savedUser.setLastName("Smith Doe"); diff --git a/codehive-backend/src/test/java/com/github/codehive/service/RecoveryPasswordServiceTest.java b/codehive-backend/src/test/java/com/github/codehive/service/RecoveryPasswordServiceTest.java index 9f1d840..bba3c5f 100644 --- a/codehive-backend/src/test/java/com/github/codehive/service/RecoveryPasswordServiceTest.java +++ b/codehive-backend/src/test/java/com/github/codehive/service/RecoveryPasswordServiceTest.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -61,7 +62,7 @@ class RecoveryPasswordServiceTest { void setUp() { // Setup test user testUser = new User(); - testUser.setId(1L); + testUser.setId(UUID.fromString("00000000-0000-0000-0000-000000000001")); testUser.setEmail("test@example.com"); testUser.setPassword("oldEncodedPassword"); testUser.setName("John"); @@ -72,7 +73,7 @@ void setUp() { // Setup valid token validToken = new PasswordResetToken(); - validToken.setId(1L); + validToken.setId(UUID.fromString("00000000-0000-0000-0000-000000000011")); validToken.setToken("valid-token-123"); validToken.setExpiryDate(LocalDateTime.now().plusMinutes(10)); validToken.setUsed(false); @@ -189,12 +190,12 @@ void sendPasswordResetEmail_InvalidatesOldUnusedTokens() { String email = "test@example.com"; PasswordResetToken oldToken1 = new PasswordResetToken(); - oldToken1.setId(10L); + oldToken1.setId(UUID.fromString("00000000-0000-0000-0000-000000000020")); oldToken1.setToken("old-token-1"); oldToken1.setUsed(false); - + PasswordResetToken oldToken2 = new PasswordResetToken(); - oldToken2.setId(11L); + oldToken2.setId(UUID.fromString("00000000-0000-0000-0000-000000000021")); oldToken2.setToken("old-token-2"); oldToken2.setUsed(false); diff --git a/codehive-frontend/ARCHITECTURE.md b/codehive-frontend/ARCHITECTURE.md index b500270..bd9e702 100644 --- a/codehive-frontend/ARCHITECTURE.md +++ b/codehive-frontend/ARCHITECTURE.md @@ -5,18 +5,25 @@ Este documento define los estándares de organización y desarrollo del frontend ## 1. Estructura del Proyecto ### app/core/ + Infraestructura global requerida para el funcionamiento base de la aplicación. -- **Router:** Configuración y agregación de rutas globales. -- **Providers:** Contextos globales (Autenticación, Tematización). -- **Config:** Variables de entorno y constantes técnicas. + +* **Router:** Configuración y agregación de rutas globales. +* **Providers:** Contextos globales (Autenticación, Tematización). +* **Config:** Variables de entorno y constantes técnicas. ### app/shared/ + Componentes y utilidades agnósticas al dominio del negocio. -- **Components:** UI atómica (ej. `Button`, `Input`) y componentes de Layout. -- **Types:** Interfaces compartidas entre múltiples dominios (ej. `User`, `ApiResponse`). -- **Hooks/Utils:** Lógica y funciones de utilidad reutilizables. + +* **Components:** UI atómica (ej. `Button`, `Input`) y componentes de Layout. +* **Types:** Interfaces compartidas entre múltiples dominios (ej. `User`, `ApiResponse`). +* **Hooks/Utils:** Lógica y funciones de utilidad reutilizables. +* **Constants:** Configuraciones reutilizables compartidas globalmente. +* **Layouts:** Layouts reutilizables utilizados por múltiples módulos o tipos de usuario. ### app/features/ + Módulos funcionales que encapsulan una unidad de negocio completa. --- @@ -25,12 +32,16 @@ Módulos funcionales que encapsulan una unidad de negocio completa. Se debe seguir estrictamente el uso de kebab-case para archivos de lógica y PascalCase para componentes React. -- **Componentes:** `NombreComponente.tsx` (PascalCase). -- **Páginas:** `NombrePage.tsx` (PascalCase + sufijo Page). -- **Servicios:** `nombre.service.ts` (kebab-case). -- **API:** `nombre.api.ts` (kebab-case). -- **Tipos:** `nombre.types.ts` (kebab-case). -- **Rutas:** `routes.ts`. +* **Componentes:** `NombreComponente.tsx` (PascalCase). +* **Páginas:** `NombrePage.tsx` (PascalCase + sufijo Page). +* **Hooks:** `useNombreHook.ts`. +* **Servicios:** `nombre.service.ts` (kebab-case). +* **API:** `nombre.api.ts` (kebab-case). +* **Tipos:** `nombre.types.ts` (kebab-case). +* **Constantes:** `nombre.constants.ts`. +* **Configuraciones:** `nombre.config.ts`. +* **Mocks/Data temporales:** `nombre.data.ts`. +* **Rutas:** `routes.ts`. --- @@ -38,12 +49,16 @@ Se debe seguir estrictamente el uso de kebab-case para archivos de lógica y Pas Cada directorio dentro de `features/` debe contener exclusivamente los elementos necesarios para su funcionamiento: -1. **api/**: Funciones de comunicación con el servidor. -2. **services/**: Orquestación de datos y lógica de negocio. -3. **types/**: Definiciones de interfaces exclusivas del módulo. -4. **pages/**: Componentes de ruta que gestionan estado y composición. -5. **components/**: Sub-componentes exclusivos del dominio. -6. **routes.ts**: Definición de rutas del módulo para su exportación al Core. +1. **api/**: Funciones de comunicación con el servidor. +2. **services/**: Orquestación de datos y lógica de negocio. +3. **types/**: Definiciones de interfaces exclusivas del módulo. +4. **pages/**: Componentes de ruta que gestionan estado y composición. +5. **components/**: Sub-componentes exclusivos del dominio. +6. **hooks/**: Hooks específicos de la característica. +7. **utils/**: Funciones auxiliares exclusivas del módulo. +8. **config/**: Configuraciones, navegación, labels y constantes del módulo. +9. **data/**: Datos mock, temporales o estructuras estáticas desacopladas de la vista. +10. **routes.ts**: Definición de rutas del módulo para su exportación al Core. --- @@ -51,18 +66,47 @@ Cada directorio dentro de `features/` debe contener exclusivamente los elementos Para implementar una nueva característica (ej. `assignments`): -1. Crear directorio en `app/features/assignments/`. -2. Definir interfaces en `types/assignments.types.ts`. -3. Implementar llamadas en `api/assignments.api.ts` y lógica en `services/assignments.service.ts`. -4. Crear vistas en `pages/` (ej. `AssignmentsListPage.tsx`). -5. Definir rutas en `routes.ts` y registrarlas en `app/core/router/routes.ts`. +1. Crear directorio en `app/features/assignments/`. +2. Definir interfaces en `types/assignments.types.ts`. +3. Implementar llamadas en `api/assignments.api.ts` y lógica en `services/assignments.service.ts`. +4. Crear componentes reutilizables dentro de `components/`. +5. Crear vistas en `pages/` (ej. `AssignmentsListPage.tsx`). +6. Separar configuraciones, navegación y constantes en `config/`. +7. Mantener mocks o datos temporales dentro de `data/`. +8. Definir rutas en `routes.ts` y registrarlas en `app/core/router/routes.ts`. --- ## 5. Estándares de Implementación -- **Proximidad**: Todo recurso utilizado exclusivamente por una característica debe residir dentro de su directorio. -- **Acoplamiento**: Una característica no debe importar componentes o lógica interna de otra característica. La comunicación debe realizarse a través de servicios compartidos o el estado global. -- **Simplicidad**: Se prohíbe la creación de capas de abstracción (como archivos `index.ts` de re-exportación) que no aporten funcionalidad técnica o claridad estructural. -- **Promoción a Shared**: Un elemento solo se moverá a `app/shared/` cuando sea requerido por dos o más características independientes. -- **Mantenibilidad**: Se prioriza la legibilidad del código sobre la brevedad. Evitar patrones de sobreingeniería que dificulten la trazabilidad de la lógica. +* **Proximidad**: Todo recurso utilizado exclusivamente por una característica debe residir dentro de su directorio. + +* **Acoplamiento**: Una característica no debe importar componentes o lógica interna de otra característica. La comunicación debe realizarse a través de servicios compartidos o el estado global. + +* **Responsabilidad Única**: Las páginas (`pages/`) deben enfocarse principalmente en composición de componentes, manejo de estado y coordinación de la vista. La lógica reutilizable, configuraciones y componentes complejos deben extraerse a archivos independientes. + +* **Separación de Componentes**: Componentes visuales reutilizables o de gran tamaño no deben declararse directamente dentro de páginas cuando puedan reutilizarse o mantenerse de forma independiente. + +* **Configuración Desacoplada**: Navegación, labels, iconos, configuraciones visuales y estructuras estáticas deben separarse en archivos de configuración o constantes, evitando saturar las páginas principales. + +* **Datos Temporales**: Todo mock, dato temporal o estructura estática utilizada durante desarrollo debe mantenerse fuera de las páginas en directorios `data/` o `mocks/`. + +* **Reutilización**: Si múltiples módulos comparten la misma estructura visual o lógica de layout, debe priorizarse la reutilización mediante componentes parametrizables antes que la duplicación. + +* **Layouts Compartidos**: Layouts generales como dashboards deben diseñarse como componentes reutilizables configurables mediante props, evitando implementaciones duplicadas para cada tipo de usuario. + +* **Consistencia de Rutas**: Las rutas deben seguir una convención uniforme y predecible. Evitar estructuras inconsistentes entre módulos equivalentes. + +* **Simplicidad**: Se prohíbe la creación de capas de abstracción (como archivos `index.ts` de re-exportación) que no aporten funcionalidad técnica o claridad estructural. + +* **Promoción a Shared**: Un elemento solo se moverá a `app/shared/` cuando sea requerido por dos o más características independientes. + +* **Mantenibilidad**: Se prioriza la legibilidad del código sobre la brevedad. Evitar patrones de sobreingeniería que dificulten la trazabilidad de la lógica. + +* **Evitar Componentes Monolíticos**: Ningún archivo de página debe concentrar múltiples responsabilidades visuales, estados complejos, configuraciones extensas y lógica de negocio simultáneamente. + +* **Escalabilidad**: Toda nueva implementación debe considerar la posibilidad de crecimiento futuro del módulo, priorizando modularidad y extensibilidad desde etapas tempranas. + +* **Consistencia Visual**: Se deben reutilizar componentes visuales e iconografía existentes antes de crear implementaciones personalizadas inline. + +* **Utilidades Compartidas**: Funciones reutilizadas por múltiples módulos o componentes deben trasladarse a `shared/utils` o a utilidades específicas de la feature correspondiente. diff --git a/codehive-frontend/DESIGN.md b/codehive-frontend/DESIGN.md new file mode 100644 index 0000000..a3f9e33 --- /dev/null +++ b/codehive-frontend/DESIGN.md @@ -0,0 +1,386 @@ +# CodeHive Design System + +Reference guide for building pages consistent with the landing page style. + +--- + +## Color Palette + +All colors are defined as Tailwind tokens in `app/styles/global.css`. + +### Brand Colors + +| Token | Hex | Usage | +|---|---|---| +| `imperial` | `#00296B` | Darkest blue — gradients, dark text on light bg | +| `french` | `#003F88` | Mid blue — gradients, secondary accents | +| `azure` | `#00509D` | Primary blue — links, icons, borders, buttons | +| `yellow` | `#FDC500` | Primary gold — dark mode accents, badges, CTA | +| `gold` | `#FFD500` | Lighter gold — gradient pairs with yellow | + +### Extended Shades + +| Token | Usage | +|---|---| +| `azure-light` | Lighter blue tints, text in dark mode | +| `french-light` | Mid-blue text or hover states | +| `yellow-light` | Soft gold highlights | +| `gold-light` | Softer gold fills | + +### Dark Mode Surfaces + +| Token | Hex | Usage | +|---|---|---| +| `dark-bg` | `#0a0f1a` | Page background | +| `dark-surface` | `#111827` | Section backgrounds, alternate rows | +| `dark-card` | `#1a2234` | Cards, panels | + +### Light Mode Surfaces + +Use Tailwind defaults: `white`, `gray-50`, `gray-100`, `gray-200`. + +--- + +## Dark Mode + +The project uses the `class` strategy. The `.dark` class is toggled on the `` element via `ThemeProvider`. Every element needs both a light and a dark variant. + +**Pattern:** +``` +bg-white dark:bg-dark-card +text-gray-900 dark:text-white +text-gray-600 dark:text-gray-400 +border-gray-200 dark:border-gray-700/50 +``` + +**Accent swaps** — `azure` in light mode becomes `yellow` in dark mode: +``` +text-azure dark:text-yellow +bg-azure/10 dark:bg-yellow/10 +border-azure/20 dark:border-yellow/20 +hover:text-azure dark:hover:text-yellow +``` + +--- + +## Typography + +Font family: **Inter** (loaded via `--font-sans` in `global.css`). + +### Scale + +| Role | Classes | +|---|---| +| Hero H1 | `text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold leading-tight` | +| Section H2 | `text-3xl sm:text-4xl lg:text-5xl font-bold` | +| Card H3 | `text-xl font-semibold` | +| Body large | `text-lg text-gray-600 dark:text-gray-400` | +| Body default | `text-base text-gray-600 dark:text-gray-400` | +| Label / badge | `text-sm font-medium` | +| Caption | `text-xs text-gray-500` | + +### Gradient Text Utilities + +```css +.gradient-text /* blue: azure → french → imperial */ +.gradient-text-gold /* gold: yellow → gold */ +``` + +Usage example: +```tsx +

+ Everything you need to{" "} + teach coding +

+``` + +--- + +## Layout + +### Page Container + +All sections use a consistent centered container: +``` +max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 +``` + +### Section Padding + +``` +py-24 lg:py-32 +``` + +### Section Header (centered) + +Every content section opens with an identical header block: +```tsx +
+ {/* Label badge */} +
+ + Section Label + +
+ + {/* Title */} +

+ Title with a highlighted word +

+ + {/* Subtitle */} +

+ Supporting description text. +

+
+``` + +### Grids + +| Columns | Classes | +|---|---| +| 2-col | `grid lg:grid-cols-2 gap-12 lg:gap-16 items-center` | +| 3-col cards | `grid md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8` | +| 4-col steps | `grid md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-6` | + +--- + +## Components + +### Buttons + +Three global utilities defined in `global.css`: + +```css +.btn-primary /* blue gradient, white text */ +.btn-secondary /* gold gradient, imperial text */ +.btn-outline /* bordered, swaps fill on hover */ +``` + +Always pair with `inline-flex items-center justify-center gap-2` for icon alignment. + +### Cards + +Standard content card: +``` +bg-white dark:bg-dark-card +rounded-2xl p-6 lg:p-8 +border border-gray-200 dark:border-gray-700/50 +hover:border-azure/50 dark:hover:border-yellow/50 +transition-all duration-500 +hover:shadow-xl hover:shadow-azure/5 dark:hover:shadow-yellow/5 +hover:-translate-y-1 +``` + +Larger rounded cards (e.g. creator profiles, contact form): +``` +rounded-3xl p-8 lg:p-10 +``` + +### Badge / Label Pill + +```tsx +
+ + + Label text + +
+``` + +### Icon Box + +Square icon container for feature cards: +``` +inline-flex items-center justify-center w-14 h-14 rounded-xl +bg-gradient-to-br from-azure to-french /* or other brand pair */ +text-white +group-hover:scale-110 transition-transform duration-300 +``` + +### Glass Panel + +```css +.glass /* bg-white/70 dark:bg-dark-card/70 backdrop-blur-md border border-gray-200/50 dark:border-gray-700/50 */ +``` + +Used for the hero illustration card and scroll-aware header. + +### Form Inputs + +``` +w-full px-4 py-3 rounded-xl +border border-gray-200 dark:border-gray-700 +bg-white dark:bg-dark-surface +text-gray-900 dark:text-white +placeholder:text-gray-400 dark:placeholder:text-gray-500 +focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-yellow +focus:border-transparent transition-all duration-200 +``` + +Labels: +``` +block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 +``` + +### Gradient CTA Banner + +Full-bleed banner used in the Footer: +``` +rounded-3xl +bg-gradient-to-br from-imperial via-french to-azure +p-8 lg:p-16 text-center +``` + +Use `btn-secondary` (gold) for the primary action inside this banner so it contrasts with the blue background. + +--- + +## Backgrounds & Decorations + +### Section Backgrounds + +Alternate sections between transparent (inherits page bg) and: +``` +bg-gray-50 dark:bg-dark-surface +``` + +Add hairline separators on alternating sections: +``` +absolute top-0 / bottom-0 left-0 right-0 h-px +bg-gradient-to-r from-transparent via-azure/20 dark:via-yellow/20 to-transparent +``` + +### Ambient Gradient Orbs + +Large, blurred orbs placed absolutely behind content: +``` +absolute w-96 h-96 rounded-full blur-3xl +bg-azure/20 dark:bg-azure/10 /* blue orb */ +bg-yellow/20 dark:bg-yellow/10 /* gold orb */ +``` + +Pair them at opposite corners (top-left / bottom-right). + +### Grid Pattern (hero only) + +``` +bg-[linear-gradient(rgba(0,80,157,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,80,157,0.03)_1px,transparent_1px)] +dark:bg-[linear-gradient(rgba(253,197,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(253,197,0,0.03)_1px,transparent_1px)] +bg-[size:60px_60px] +``` + +### Card Top Accent + +A thin colored stripe on the top edge of creator / profile cards: +``` +absolute top-0 left-0 right-0 h-1 rounded-t-3xl +bg-gradient-to-r from-azure to-french /* or yellow/gold, french/imperial */ +``` + +--- + +## Animations + +All keyframes and utility classes are in `global.css`. + +| Class | Effect | +|---|---| +| `animate-float` | Gentle vertical bob (6 s loop) — floating cards, orbs | +| `animate-pulse-glow` | Yellow glow pulse (3 s loop) — status indicators | +| `animate-slide-up` | One-shot slide up from 30 px | +| `animate-fade-in` | One-shot opacity fade | +| `animate-slide-in-right` | One-shot slide from right (page transitions) | +| `animate-slide-in-left` | One-shot slide from left (page transitions) | +| `animate-fade-scale` | One-shot scale-up fade (page transitions) | +| `animate-bounce` | Tailwind built-in — scroll indicator | +| `animate-pulse` | Tailwind built-in — live status dots | +| `animate-spin` | Tailwind built-in — loading spinners | + +### Scroll-triggered Entry + +Use `IntersectionObserver` + React state to stagger card entrances: +```tsx +const [visibleItems, setVisibleItems] = useState([]); +// observer adds index to visibleItems when card enters viewport + +className={`transition-all duration-500 ${ + visibleItems.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" +}`} +style={{ transitionDelay: `${index * 100}ms` }} +``` + +### Mount Entry (single element) + +```tsx +const [isVisible, setIsVisible] = useState(false); +useEffect(() => { setIsVisible(true); }, []); + +className={`transition-all duration-1000 ${ + isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10" +}`} +``` + +Use `delay-300` on secondary elements (e.g. hero illustration). + +--- + +## Icons + +Use inline SVG only. No icon library imports. + +**Stroke pattern** (all icons follow this): +```tsx + + + +``` + +- `strokeWidth={1.5}` for decorative / feature icons +- `strokeWidth={2}` for UI / action icons (arrows, checkmarks, menu) + +--- + +## Gradients Reference + +Common brand gradient pairs used throughout: + +| Name | Classes | +|---|---| +| Blue primary | `from-azure to-french` | +| Blue deep | `from-french to-imperial` | +| Blue full | `from-imperial via-french to-azure` | +| Gold | `from-yellow to-gold` | +| Mixed | `from-azure to-azure-light` | +| Reversed gold | `from-gold to-yellow` | + +For text use `.gradient-text` or `.gradient-text-gold` utilities. +For backgrounds use `bg-gradient-to-br` (or `to-r`). + +--- + +## Responsive Breakpoints + +Follow Tailwind's defaults. The landing page uses three primary tiers: + +| Tier | Prefix | Notes | +|---|---|---| +| Mobile | *(none)* | Single column, stacked layout | +| Tablet | `sm:` | 2-col grids, horizontal button groups | +| Desktop | `lg:` | Full multi-column grids, larger padding/type | + +Navigation collapses to a hamburger below `lg`. + +--- + +## Accessibility + +- Every icon-only button has `aria-label`. +- Social link anchors carry `aria-label` matching the platform name. +- Form inputs are associated with `
- {/* Divider */} -
-
-
-
-
- - Continue with your credentials - -
-
- - {/* Login form */} + {/* Form fields */}
- {/* Email or Enrollment Number field */} -
+ {/* Email / Identifier */} +
-
+
setIdentifier(e.target.value)} - placeholder="you@example.com or 2024123456" + placeholder="name@university.edu" required - className="w-full pl-12 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 - bg-white dark:bg-dark-card text-gray-900 dark:text-white - placeholder:text-gray-400 dark:placeholder:text-gray-500 - focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-yellow + className="w-full pl-12 pr-4 py-3.5 rounded-lg + border border-gray-200 dark:border-white/10 + bg-white dark:bg-white/5 + text-gray-900 dark:text-white text-base + placeholder:text-gray-400 dark:placeholder:text-gray-600 + focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-[#6ba3f5] focus:border-transparent transition-all duration-200" />
- {/* Password field */} -
- -
+ {/* Password */} +
+
+ + + Forgot password? + +
+
setPassword(e.target.value)} placeholder="••••••••" required - className="w-full pl-12 pr-12 py-3 rounded-xl border border-gray-200 dark:border-gray-700 - bg-white dark:bg-dark-card text-gray-900 dark:text-white - placeholder:text-gray-400 dark:placeholder:text-gray-500 - focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-yellow + className="w-full pl-12 pr-12 py-3.5 rounded-lg + border border-gray-200 dark:border-white/10 + bg-white dark:bg-white/5 + text-gray-900 dark:text-white text-base + placeholder:text-gray-400 dark:placeholder:text-gray-600 + focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-[#6ba3f5] focus:border-transparent transition-all duration-200" />
- {/* Remember me and Forgot password */} -
- - + setRememberMe(e.target.checked)} + className="w-4 h-4 rounded border-gray-300 dark:border-white/20 + text-azure focus:ring-azure bg-white dark:bg-white/5" + /> + + Remember me for 30 days +
- {/* Submit button */} + {/* Submit */} +
+
- {/* Footer */} -

- By signing in, you agree to our{" "} + {/* Footer – pinned to bottom */} +

+
diff --git a/codehive-frontend/app/features/auth/pages/RecoveryPasswordPage.tsx b/codehive-frontend/app/features/auth/pages/RecoveryPasswordPage.tsx index 8756e74..b8458ad 100644 --- a/codehive-frontend/app/features/auth/pages/RecoveryPasswordPage.tsx +++ b/codehive-frontend/app/features/auth/pages/RecoveryPasswordPage.tsx @@ -16,12 +16,8 @@ export function RecoveryPasswordPage() { setMounted(true); }, []); - // Check if identifier is an email - const isEmail = (value: string) => { - return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test( - value.trim(), - ); - }; + const isEmail = (value: string) => + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value.trim()); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -29,9 +25,7 @@ export function RecoveryPasswordPage() { const usedEnrollmentNumber = !isEmail(identifier); setIsEnrollmentNumber(usedEnrollmentNumber); try { - await AuthService.forgotPassword({ - identifier, - }); + await AuthService.forgotPassword({ identifier }); setIsSubmitted(true); } catch (error: unknown) { const message = @@ -43,218 +37,217 @@ export function RecoveryPasswordPage() { setIsLoading(false); }; + const features = [ + "Secure password reset link", + "Link expires in 24 hours", + "One-time use only", + ]; + return ( -
- {/* Left side - Branding/Illustration */} +
+ {/* ── Left panel ── */}
- {/* Background decorations */} -
-
-
-
- - {/* Grid pattern */} + {/* Blobs */} +
+
+
- {/* Content */} -
- {/* Logo */} - - CodeHive - CodeHive - + {/* Logo – top */} + + CodeHive + CodeHive + -

- Forgot your -
- password? -

+ {/* Content – vertically centered */} +
+ {isSubmitted ? ( +
+ {/* Badge */} +
+ + Security Protocol +
-

- No worries! It happens to the best of us. Enter your email or - enrollment number and we'll send you instructions to reset your - password. -

+
+

+ Check your
+ email! +

+

+ We've dispatched an encrypted verification link to your registered inbox. Please complete the process to resume your engineering workspace. +

+
- {/* Security features */} -
- {[ - { text: "Secure password reset link" }, - { text: "Link expires in 24 hours" }, - { text: "One-time use only" }, - ].map((item) => ( -
-
- - - +
+
+
+ + + +
+
+

Link expires in 24 hours

+

For security reasons, this link will self-destruct after one day.

+
- {item.text} -
- ))} -
- {/* Decorative lock illustration */} -
-
-
-
- - - +
+
+ + + +
+
+

Check your spam folder

+

If it's not in your inbox, our carrier pigeons might have taken a detour.

+
-
- - - + +
+
+ + + +
+
+

One-time use only

+

The link is uniquely generated and can only be used to verify once.

+
-

- Your account security is our priority -

-
+ ) : ( +
+
+

+ Forgot your
+ password? +

+

+ No worries! It happens to the best of us. Enter your email and we'll + send you instructions to reset your password. +

+
+ +
    + {features.map((feature) => ( +
  • +
    + + + +
    + {feature} +
  • + ))} +
+ +
+
+
+
+ + + +
+ + + +
+
+
+
+
+ )}
+ + {/* Bottom text */} +

+ Your account security is our priority +

- {/* Right side - Recovery Form */} + {/* ── Right panel ── */}
- {/* Header with theme toggle */} -
- {/* Mobile logo */} - - CodeHive - CodeHive + {/* Top bar */} +
+ + CodeHive + CodeHive -
- - {/* Theme toggle */} - +
+ +
- {/* Form container */} -
-
+ {/* Form – centered vertically */} +
+
{!isSubmitted ? ( <> - {/* Header */} -
- {/* Icon */} -
+ {/* Icon + heading */} +
+
- +
- -

- Reset password -

-

- Enter the email address or enrollment number associated with - your account -

+
+

+ Reset password +

+

+ Enter the email address associated with your account +

+
- {/* Recovery form */} -
- {/* Email or Enrollment Number field */} -
+ {/* Form */} + +
-
+
@@ -272,44 +265,32 @@ export function RecoveryPasswordPage() { type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} - placeholder="you@example.com or 2024123456" + placeholder="you@example.com" required - className="w-full pl-12 pr-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 - bg-white dark:bg-dark-card text-gray-900 dark:text-white - placeholder:text-gray-400 dark:placeholder:text-gray-500 - focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-yellow + className="w-full pl-12 pr-4 py-3.5 rounded-lg + border border-gray-200 dark:border-white/10 + bg-white dark:bg-white/5 + text-gray-900 dark:text-white text-base + placeholder:text-gray-400 dark:placeholder:text-gray-600 + focus:outline-none focus:ring-2 focus:ring-azure dark:focus:ring-[#6ba3f5] focus:border-transparent transition-all duration-200" />
- {/* Submit button */} - {/* Back to login */} -
+ {/* Back to sign in */} + - - ) : ( - /* Success state */ -
- {/* Success icon */} -
- - - -
-

- Check your email -

-

- {isEnrollmentNumber - ? "We've sent password reset instructions to the email associated with enrollment number" - : "We've sent password reset instructions to"} -

-

- {identifier} -

- - {/* Email icon animation */} -
- - - + {/* Security tip */} +
+
+
+

Security tip:

+

+ The reset link will be sent to your registered email address + and will expire in{" "} + 24 hours for security purposes. +

+
+ + ) : ( + /* ── Success state ── */ +
+
+ {/* Header – centered */} +
+
+ + + +
+
+

Email sent!

+

+ {isEnrollmentNumber + ? "We've sent a reset link to the email associated with enrollment number" + : "We've sent a password reset link to:"} +

+ {identifier} +
+
-

- Didn't receive the email? Check your spam folder or -

- - {/* Resend button */} - + {/* Callout box */} +
+ + + +
+

Important Instruction

+

Ensure you use the link in the same browser session for optimal security synchronization.

+
+
- {/* Divider */} -
-
-
+ {/* Actions */} +
+ +
-
- - or - + + {/* Footer */} +
+

+ Having trouble?{" "} + + Contact Technical Support + +

- - {/* Back to login */} - - - - - Back to sign in -
)}
diff --git a/codehive-frontend/app/features/auth/services/auth.service.ts b/codehive-frontend/app/features/auth/services/auth.service.ts index a970fbc..199c8b5 100644 --- a/codehive-frontend/app/features/auth/services/auth.service.ts +++ b/codehive-frontend/app/features/auth/services/auth.service.ts @@ -32,6 +32,10 @@ export const AuthService = { return authApi.getMe(getAuthToken()); }, + updatePassword(currentPassword: string, newPassword: string) { + return authApi.updatePassword(currentPassword, newPassword, getAuthToken()); + }, + forgotPassword(request: ForgotPasswordRequest) { return authApi.forgotPassword(request); }, diff --git a/codehive-frontend/app/features/dashboard/api/dashboard.api.ts b/codehive-frontend/app/features/dashboard/api/dashboard.api.ts deleted file mode 100644 index 9cb4649..0000000 --- a/codehive-frontend/app/features/dashboard/api/dashboard.api.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Group } from "../types/dashboard.types"; -import { API_BASE_URL } from "~/core/config/env"; -import { getAuthToken } from "~/core/storage/token.storage"; - -const MOCK_GROUPS: Group[] = [ - { - id: "1", - name: "Fundamentos de programación", - subject: "Primer semestre", - colorClass: "bg-blue-600", - pendingPractices: 2, - inProgress: 1, - nextDeadline: "May 2, 2026", - }, - { - id: "2", - name: "Algoritmos y Estructuras de Datos", - subject: "Segundo semestre", - colorClass: "bg-teal-500", - pendingPractices: 0, - inProgress: 1, - nextDeadline: "May 5, 2026", - }, - { - id: "3", - name: "Análisis y Diseño de Algoritmos", - subject: "Tercer semestre", - colorClass: "bg-orange-500", - pendingPractices: 1, - inProgress: 0, - nextDeadline: "April 30, 2026", - }, - { - id: "4", - name: "Algoritmos y Estructuras de datos", - subject: "Segundo Semestre", - colorClass: "bg-yellow-500", - pendingPractices: 0, - inProgress: 2, - nextDeadline: "May 8, 2026", - }, -]; - -export const getGroups = async (): Promise => { - // TODO: Replace with real backend endpoint once implemented - // Simulate network delay - return new Promise((resolve) => setTimeout(() => resolve(MOCK_GROUPS), 500)); -}; - -export const updatePassword = async (currentPassword: string, newPassword: string) => { - const token = getAuthToken(); - if (!token) { - throw new Error("Your session is invalid or has expired. Please log in again."); - } - const response = await fetch(`${API_BASE_URL}/api/auth/me/password`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ currentPassword, newPassword }), - }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.message || "Failed to update password"); - } - return response.json(); -}; diff --git a/codehive-frontend/app/features/dashboard/components/DashboardLayout.tsx b/codehive-frontend/app/features/dashboard/components/DashboardLayout.tsx deleted file mode 100644 index 37f6bf9..0000000 --- a/codehive-frontend/app/features/dashboard/components/DashboardLayout.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { useState } from "react"; -import { Link } from "react-router"; -import { - Menu, - Bell, - Settings, - Sun, - Moon, - Code2, - Home, - ClipboardList, - Archive, - X -} from "lucide-react"; -import { useAuth } from "~/core/providers/AuthProvider"; -import { useTheme } from "~/core/providers/ThemeProvider"; -import { ProfileSettingsModal } from "./ProfileSettingsModal"; - -interface DashboardLayoutProps { - children: React.ReactNode; -} - -export const DashboardLayout: React.FC = ({ children }) => { - const { user } = useAuth(); - const { theme, toggleTheme } = useTheme(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); - - const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen); - - return ( -
- {/* Navbar */} -
-
- - - - - CodeHive - - - -
- -
- - - - -
- -
-
- {user?.name} - {user?.role?.toLowerCase() || 'Student'} -
- -
-
-
- -
- {/* Mobile Sidebar Overlay */} - {isSidebarOpen && ( -
setIsSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main Content */} -
-
- {children} -
-
-
- - setIsProfileModalOpen(false)} - /> -
- ); -}; - -const SidebarItem = ({ icon, label, to, active = false }: { icon: React.ReactNode, label: string, to: string, active?: boolean }) => ( - -
- {icon} -
- {label} - -); diff --git a/codehive-frontend/app/features/dashboard/components/GroupCard.tsx b/codehive-frontend/app/features/dashboard/components/GroupCard.tsx deleted file mode 100644 index d2b41ef..0000000 --- a/codehive-frontend/app/features/dashboard/components/GroupCard.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import type { Group } from "../types/dashboard.types"; - -interface GroupCardProps { - group: Group; -} - -export const GroupCard: React.FC = ({ group }) => { - return ( -
-
- {/* Decorative elements */} -
-

{group.name}

-

{group.subject}

-
- -
-
- Pending Practices - {group.pendingPractices} -
-
- In Progress - {group.inProgress} -
-
- Next Deadline - {group.nextDeadline} -
-
-
- ); -}; diff --git a/codehive-frontend/app/features/dashboard/components/ProfileSettingsModal.tsx b/codehive-frontend/app/features/dashboard/components/ProfileSettingsModal.tsx deleted file mode 100644 index aebf2e9..0000000 --- a/codehive-frontend/app/features/dashboard/components/ProfileSettingsModal.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useState } from "react"; -import { X, Lock, User, Loader2, LogOut } from "lucide-react"; -import { sileo } from "sileo"; -import { updatePassword } from "../api/dashboard.api"; -import { useAuth } from "~/core/providers/AuthProvider"; - -interface ProfileSettingsModalProps { - isOpen: boolean; - onClose: () => void; -} - -export const ProfileSettingsModal: React.FC = ({ isOpen, onClose }) => { - const { user, logout } = useAuth(); - const [activeTab, setActiveTab] = useState<"profile" | "security">("profile"); - - // Password State - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [isUpdatingPassword, setIsUpdatingPassword] = useState(false); - - if (!isOpen) return null; - - - const handleUpdatePassword = async (e: React.FormEvent) => { - e.preventDefault(); - if (!currentPassword || !newPassword) return; - if (newPassword.length < 8) { - sileo.error({ title: "New password must be at least 8 characters" }); - return; - } - - setIsUpdatingPassword(true); - try { - await updatePassword(currentPassword, newPassword); - sileo.success({ title: "Password updated successfully" }); - setCurrentPassword(""); - setNewPassword(""); - } catch (error: any) { - sileo.error({ title: error.message || "Failed to update password" }); - } finally { - setIsUpdatingPassword(false); - } - }; - - const handleLogout = () => { - onClose(); - sileo.success({ title: "Successfully logged out!" }); - logout(); - }; - - return ( -
-
-
-

Settings

- -
- -
- - -
- -
- {activeTab === "profile" && ( -
-
-
- {user?.name?.charAt(0) || 'U'} -
-
- -
-

{user?.name} {user?.lastName}

-

{user?.email}

-
- -
- -
-
- )} - - {activeTab === "security" && ( -
-
- - setCurrentPassword(e.target.value)} - className="w-full bg-[#121212] border border-[#2a2a2a] text-white rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500 transition-colors" - placeholder="Enter current password" - required - /> -
-
- - setNewPassword(e.target.value)} - className="w-full bg-[#121212] border border-[#2a2a2a] text-white rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500 transition-colors" - placeholder="At least 8 characters" - required - minLength={8} - /> -
- -
- )} -
-
-
- ); -}; diff --git a/codehive-frontend/app/features/dashboard/pages/DashboardPage.tsx b/codehive-frontend/app/features/dashboard/pages/DashboardPage.tsx deleted file mode 100644 index d5348e3..0000000 --- a/codehive-frontend/app/features/dashboard/pages/DashboardPage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from "react"; -import { DashboardLayout } from "../components/DashboardLayout"; -import { GroupCard } from "../components/GroupCard"; -import type { Group } from "../types/dashboard.types"; -import { getGroups } from "../api/dashboard.api"; -import { useAuth } from "~/core/providers/AuthProvider"; - -export const DashboardPage = () => { - const { user } = useAuth(); - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchGroups = async () => { - try { - const data = await getGroups(); - setGroups(data); - } catch (error) { - console.error("Error fetching groups:", error); - } finally { - setIsLoading(false); - } - }; - fetchGroups(); - }, []); - - const totalPending = groups.reduce( - (acc, group) => acc + group.pendingPractices, - 0, - ); - - return ( - -
-

- Welcome back, {user?.name?.split(" ")[0] || "User"}! -

-

- You have {totalPending} pending{" "} - {totalPending === 1 ? "practice" : "practices"} across your groups -

-
- -
-

- My Groups -

- {isLoading ? ( -
- {[1, 2, 3, 4].map((n) => ( -
- ))} -
- ) : ( -
- {groups.map((group) => ( - - ))} -
- )} -
-
- ); -}; diff --git a/codehive-frontend/app/features/dashboard/routes.ts b/codehive-frontend/app/features/dashboard/routes.ts deleted file mode 100644 index 4a7566d..0000000 --- a/codehive-frontend/app/features/dashboard/routes.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { route, type RouteConfig } from "@react-router/dev/routes"; - -export const dashboardRoutes = [ - route("dashboard", "features/dashboard/routes/dashboard.tsx"), -] satisfies RouteConfig; diff --git a/codehive-frontend/app/features/dashboard/routes/dashboard.tsx b/codehive-frontend/app/features/dashboard/routes/dashboard.tsx deleted file mode 100644 index b83b4d7..0000000 --- a/codehive-frontend/app/features/dashboard/routes/dashboard.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Route } from "./+types/dashboard"; -import { ProtectedRoute } from "~/core/components/ProtectedRoute"; -import { DashboardPage } from "../pages/DashboardPage"; - -export function meta({}: Route.MetaArgs) { - return [ - { title: "Dashboard - CodeHive" }, - { name: "description", content: "CodeHive student dashboard." }, - ]; -} - -export default function Dashboard() { - return ( - - - - ); -} diff --git a/codehive-frontend/app/features/landing/components/Contact.tsx b/codehive-frontend/app/features/landing/components/Contact.tsx deleted file mode 100644 index 6275b74..0000000 --- a/codehive-frontend/app/features/landing/components/Contact.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useState } from "react"; - -export function Contact() { - const [formData, setFormData] = useState({ - name: "", - email: "", - organization: "", - message: "", - }); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isSubmitted, setIsSubmitted] = useState(false); - - const handleChange = (e: React.ChangeEvent) => { - setFormData((prev) => ({ - ...prev, - [e.target.name]: e.target.value, - })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1500)); - setIsSubmitting(false); - setIsSubmitted(true); - console.log(formData); - }; - - return ( -
- {/* Background */} -
-
-
-
- -
- {/* Header */} -
-
- - Get in Touch - -
-

- Interested in CodeHive? -

-

- We offer flexible licensing options for institutions of all sizes. - Get in touch to learn how CodeHive can transform your programming education. -

-
- -
- {/* Contact Info */} -
- {/* License Card */} -
- {/* Decorative elements */} -
-
- -
-
- - Enterprise License -
- -

- Custom Licensing Solutions -

-

- We work with universities, coding bootcamps, and educational institutions - to provide tailored licensing packages that fit your needs and budget. -

- -
- {[ - { text: "Academic & institutional pricing" }, - { text: "Custom integrations & features" }, - { text: "Dedicated support & SLA" }, - { text: "Scalable for any class size" }, - ].map((item) => ( -
- {item.text} -
- ))} -
-
-
- - {/* Contact methods */} - -
- - {/* Contact Form */} -
- {isSubmitted ? ( -
-
- - - -
-

- Message Sent! -

-

- Thank you for your interest. We'll get back to you within 24 hours. -

- -
- ) : ( - <> -

- Request Information -

-

- Fill out the form below and we'll be in touch shortly. -

- -
-
-
- - -
-
- - -
-
- -
- - -
- -
- -