diff --git a/ELIXIR_MIGRATION_ROADMAP.md b/ELIXIR_MIGRATION_ROADMAP.md deleted file mode 100644 index 0b5ab9f9..00000000 --- a/ELIXIR_MIGRATION_ROADMAP.md +++ /dev/null @@ -1,916 +0,0 @@ -# Elixir Migration Roadmap - -## Overview - -This document outlines a comprehensive, step-by-step plan for migrating the CodinCod backend from Node.js/Fastify to Elixir/Phoenix while maintaining the Svelte/SvelteKit frontend with minimal disruption. - -### Migration Philosophy - -- **Incremental**: Migrate feature by feature, not all at once -- **Parallel Running**: Run both backends simultaneously during transition -- **Frontend Unchanged**: Keep Svelte frontend intact throughout -- **Data Migration**: Careful planning for PostgreSQL migration -- **Risk Mitigation**: Each phase can be rolled back independently - -### Technology Stack (Target) - -**Backend**: -- Elixir 1.16+ -- Phoenix 1.7+ (REST APIs) -- Phoenix LiveView (WebSockets/real-time) -- Ecto (ORM) -- PostgreSQL 16+ -- Oban (background jobs) - -**Frontend** (Unchanged): -- Svelte 5 -- SvelteKit -- TypeScript -- Tailwind CSS - ---- - -## Phase 0: Preparation & Infrastructure (3-4 weeks) - -### Week 1-2: Learning & Setup - -**Objectives**: -- Team familiarization with Elixir/Phoenix -- Development environment setup -- Initial architecture decisions - -**Tasks**: -1. **Learn Elixir Fundamentals** - - Pattern matching, immutability, functional programming - - OTP (GenServers, Supervisors, Applications) - - Phoenix framework basics - - Ecto query language - -2. **Set Up Development Environment** - ```bash - # Install Elixir and Phoenix - brew install elixir - mix archive.install hex phx_new - - # Create new Phoenix project - mix phx.new codincod_api --no-html --no-assets - cd codincod_api - mix deps.get - ``` - -3. **Architecture Documentation** - - Document current Node.js architecture - - Design Elixir equivalent architecture - - Plan service boundaries and contexts - -### Week 3-4: PostgreSQL Migration Planning - -**Objectives**: -- Design PostgreSQL schema -- Create migration strategy -- Set up database infrastructure - -**Tasks**: -1. **Schema Design** - - Convert MongoDB schemas to PostgreSQL tables - - Design proper foreign keys and constraints - - Plan indexes for performance - - Handle ObjectId → UUID/BigInt conversion - -2. **Migration Script Development** - - Write data migration scripts - - Create rollback procedures - - Test data integrity validation - -3. **Database Setup** - ```bash - # PostgreSQL setup - docker-compose.yml: - postgres: - image: postgres:16 - environment: - POSTGRES_DB: codincod_dev - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - "5432:5432" - ``` - -4. **Ecto Schema Creation** - ```elixir - # Example: lib/codincod_api/accounts/user.ex - defmodule CodincodApi.Accounts.User do - use Ecto.Schema - import Ecto.Changeset - - @primary_key {:id, :binary_id, autogenerate: true} - schema "users" do - field :username, :string - field :email, :string - field :password_hash, :string - field :role, :string, default: "user" - field :rating, :integer, default: 1500 - - has_many :puzzles, CodincodApi.Puzzles.Puzzle - has_many :submissions, CodincodApi.Submissions.Submission - - timestamps() - end - end - ``` - -**Deliverables**: -- Elixir development environment -- PostgreSQL database with schema -- Data migration scripts (untested) -- Architecture documentation - ---- - -## Phase 1: Authentication Service (4-6 weeks) - -### Why Start with Authentication? - -- Foundational for all other features -- Clear boundaries, well-defined API -- Relatively isolated from other services -- Good learning opportunity - -### Week 1-2: Core Authentication - -**Objectives**: -- Implement user registration and login -- JWT token generation and validation -- Password hashing with Argon2 - -**Tasks**: -1. **Create Accounts Context** - ```elixir - # lib/codincod_api/accounts.ex - defmodule CodincodApi.Accounts do - import Ecto.Query - alias CodincodApi.Repo - alias CodincodApi.Accounts.User - - def create_user(attrs) do - %User{} - |> User.changeset(attrs) - |> Repo.insert() - end - - def get_user_by_email(email) do - Repo.get_by(User, email: email) - end - - def verify_password(user, password) do - Argon2.verify_pass(password, user.password_hash) - end - end - ``` - -2. **Implement Auth Controllers** - ```elixir - # lib/codincod_api_web/controllers/auth_controller.ex - defmodule CodincodApiWeb.AuthController do - use CodincodApiWeb, :controller - - alias CodincodApi.Accounts - alias CodincodApiWeb.Auth.Guardian - - def register(conn, %{"username" => username, "email" => email, "password" => password}) do - with {:ok, user} <- Accounts.create_user(%{username: username, email: email, password: password}), - {:ok, token, _claims} <- Guardian.encode_and_sign(user) do - conn - |> put_status(:created) - |> json(%{token: token, user: user_response(user)}) - end - end - - def login(conn, %{"identifier" => identifier, "password" => password}) do - # Implementation - end - end - ``` - -3. **JWT Integration with Guardian** - ```elixir - # lib/codincod_api_web/auth/guardian.ex - defmodule CodincodApiWeb.Auth.Guardian do - use Guardian, otp_app: :codincod_api - - def subject_for_token(%{id: id}, _claims) do - {:ok, to_string(id)} - end - - def resource_from_claims(%{"sub" => id}) do - case CodincodApi.Accounts.get_user(id) do - nil -> {:error, :user_not_found} - user -> {:ok, user} - end - end - end - ``` - -4. **Authentication Plug** - ```elixir - # lib/codincod_api_web/plugs/auth.ex - defmodule CodincodApiWeb.Plugs.Auth do - import Plug.Conn - alias CodincodApiWeb.Auth.Guardian - - def init(opts), do: opts - - def call(conn, _opts) do - with ["Bearer " <> token] <- get_req_header(conn, "authorization"), - {:ok, claims} <- Guardian.decode_and_verify(token), - {:ok, user} <- Guardian.resource_from_claims(claims) do - assign(conn, :current_user, user) - else - _ -> - conn - |> put_status(:unauthorized) - |> Phoenix.Controller.json(%{error: "Unauthorized"}) - |> halt() - end - end - end - ``` - -### Week 3-4: Session Management & Middleware - -**Tasks**: -1. **Session Store (if needed)** - - Redis integration for session storage - - Token revocation mechanism - -2. **Rate Limiting** - ```elixir - # Using Hammer library - defmodule CodincodApiWeb.Plugs.RateLimit do - import Plug.Conn - alias Hammer - - def init(opts), do: opts - - def call(conn, opts) do - case Hammer.check_rate("#{conn.remote_ip}:login", 60_000, 5) do - {:allow, _count} -> conn - {:deny, _limit} -> - conn - |> put_status(:too_many_requests) - |> Phoenix.Controller.json(%{error: "Too many requests"}) - |> halt() - end - end - end - ``` - -3. **User Profile Endpoints** - - GET /api/v1/user/me - - PUT /api/v1/account - - GET /api/v1/user/:username - -### Week 5-6: Frontend Integration & Testing - -**Tasks**: -1. **Update SvelteKit Frontend** - ```typescript - // libs/types/src/core/common/config/backend-urls.ts - const ELIXIR_BACKEND = import.meta.env.VITE_ELIXIR_BACKEND_URL || 'http://localhost:4000'; - - export const backendUrls = { - // Migrate auth endpoints to Elixir - REGISTER: USE_ELIXIR_AUTH ? `${ELIXIR_BACKEND}/api/v1/register` : `${baseRoute}/register`, - LOGIN: USE_ELIXIR_AUTH ? `${ELIXIR_BACKEND}/api/v1/login` : `${baseRoute}/login`, - // ... - }; - ``` - -2. **Feature Flag System** - ```typescript - // Environment-based feature flags - export const featureFlags = { - useElixirAuth: import.meta.env.VITE_USE_ELIXIR_AUTH === 'true', - useElixirPuzzles: import.meta.env.VITE_USE_ELIXIR_PUZZLES === 'true', - // etc. - }; - ``` - -3. **Testing** - - Unit tests with ExUnit - - Integration tests for auth flow - - E2E tests with existing Playwright suite - -**Deliverables**: -- Complete authentication service in Elixir -- Frontend can use either Node.js or Elixir auth -- Feature flags for gradual rollout -- Comprehensive test coverage - ---- - -## Phase 2: Puzzle Service (4-5 weeks) - -### Week 1-2: Core Puzzle Operations - -**Objectives**: -- CRUD operations for puzzles -- Solutions and validators -- Author attribution - -**Tasks**: -1. **Puzzles Context** - ```elixir - defmodule CodincodApi.Puzzles do - alias CodincodApi.Puzzles.Puzzle - alias CodincodApi.Repo - - def list_puzzles(params) do - Puzzle - |> filter_by_difficulty(params[:difficulty]) - |> filter_by_author(params[:author]) - |> paginate(params) - |> Repo.all() - |> Repo.preload([:author, :solutions]) - end - - def create_puzzle(user, attrs) do - %Puzzle{author_id: user.id} - |> Puzzle.changeset(attrs) - |> Repo.insert() - end - end - ``` - -2. **API Endpoints** - - GET /api/v1/puzzle - - POST /api/v1/puzzle - - GET /api/v1/puzzle/:id - - PUT /api/v1/puzzle/:id - - DELETE /api/v1/puzzle/:id - -3. **Solutions & Validators** - ```elixir - defmodule CodincodApi.Puzzles.Solution do - use Ecto.Schema - - schema "solutions" do - field :code, :string - field :test_cases, :map # JSONB - - belongs_to :puzzle, CodincodApi.Puzzles.Puzzle - belongs_to :language, CodincodApi.Languages.ProgrammingLanguage - - timestamps() - end - end - ``` - -### Week 3-4: Comments & Voting - -**Tasks**: -1. **Comments System** - - Nested comments support - - Comment CRUD - - Pagination - -2. **Voting System** - - Upvote/downvote - - Vote counts - - User vote tracking - -3. **Integration with Piston API** - - HTTP client with Tesla/Finch - - Code execution proxying - - Result parsing - -### Week 5: Testing & Migration - -**Tasks**: -- Migrate puzzle data from MongoDB to PostgreSQL -- Frontend integration -- Parallel testing (both backends) - -**Deliverables**: -- Complete puzzle service -- Comments and voting -- Data migration complete - ---- - -## Phase 3: Submission Service (3-4 weeks) - -### Week 1-2: Core Submissions - -**Objectives**: -- Code submission handling -- Execution result storage -- User submission history - -**Tasks**: -1. **Submissions Context** - ```elixir - defmodule CodincodApi.Submissions do - def create_submission(user, attrs) do - %Submission{user_id: user.id} - |> Submission.changeset(attrs) - |> Repo.insert() - end - - def execute_and_store(submission) do - with {:ok, result} <- PistonClient.execute(submission.code, submission.language), - {:ok, submission} <- update_submission_result(submission, result) do - {:ok, submission} - end - end - end - ``` - -2. **Background Jobs with Oban** - ```elixir - defmodule CodincodApi.Workers.ExecuteSubmission do - use Oban.Worker - - @impl Oban.Worker - def perform(%Oban.Job{args: %{"submission_id" => id}}) do - submission = Submissions.get_submission!(id) - Submissions.execute_and_store(submission) - end - end - ``` - -### Week 3-4: Leaderboards & Stats - -**Tasks**: -- User statistics -- Puzzle solve rates -- Language popularity metrics -- Leaderboard queries (optimized with indexes) - -**Deliverables**: -- Submission service complete -- Background job processing -- Statistics and leaderboards - ---- - -## Phase 4: Real-Time Game System (6-8 weeks) - -### Week 1-3: Phoenix Channels for WebSockets - -**Objectives**: -- Migrate waiting room WebSocket logic -- Game session management -- Real-time player synchronization - -**Tasks**: -1. **Waiting Room Channel** - ```elixir - defmodule CodincodApiWeb.WaitingRoomChannel do - use CodincodApiWeb, :channel - - def join("waiting_room:lobby", _payload, socket) do - {:ok, socket} - end - - def handle_in("room:host", %{"options" => options}, socket) do - # Create room logic - broadcast(socket, "rooms:overview", %{rooms: list_rooms()}) - {:reply, {:ok, %{room_id: room_id}}, socket} - end - - def handle_in("room:join", %{"room_id" => room_id}, socket) do - # Join room logic - end - end - ``` - -2. **Game Channel** - ```elixir - defmodule CodincodApiWeb.GameChannel do - use CodincodApiWeb, :channel - - def join("game:" <> game_id, _payload, socket) do - game = Games.get_game!(game_id) - {:ok, assign(socket, :game, game)} - end - - def handle_in("game:submit", %{"code" => code}, socket) do - # Submission logic - broadcast(socket, "player:submitted", %{user: socket.assigns.current_user}) - {:noreply, socket} - end - end - ``` - -3. **Presence Tracking** - ```elixir - defmodule CodincodApiWeb.Presence do - use Phoenix.Presence, - otp_app: :codincod_api, - pubsub_server: CodincodApi.PubSub - end - ``` - -### Week 4-5: Game Logic & State Management - -**Tasks**: -- GenServer for game state -- Countdown timers -- Player submission tracking -- Score calculation - -**Example GenServer**: -```elixir -defmodule CodincodApi.GameServer do - use GenServer - - def start_link(game_id) do - GenServer.start_link(__MODULE__, game_id, name: via_tuple(game_id)) - end - - def init(game_id) do - game = Games.get_game!(game_id) - schedule_game_end(game.end_time) - {:ok, %{game: game, players: %{}}} - end - - def handle_info(:end_game, state) do - # Calculate final scores, notify players - Games.finalize_game(state.game.id) - {:stop, :normal, state} - end - - defp via_tuple(game_id) do - {:via, Registry, {CodincodApi.GameRegistry, game_id}} - end -end -``` - -### Week 6-8: Chat & Testing - -**Tasks**: -- Chat message handling -- Message persistence -- Frontend WebSocket integration -- Load testing with many concurrent games - -**Deliverables**: -- Complete real-time game system -- WebSocket channels working -- Chat functionality -- Performance tested - ---- - -## Phase 5: Moderation & Admin (2-3 weeks) - -### Week 1-2: Moderation Tools - -**Tasks**: -1. **Review System** - - Puzzle approval workflow - - Report handling - - Moderation queue - -2. **Ban System** - - Temporary/permanent bans - - Ban history - - Middleware for ban checking - -3. **Admin Dashboard API** - - User management - - System stats - - Activity logs - -**Deliverables**: -- Moderation service complete -- Admin APIs functional - ---- - -## Phase 6: Cleanup & Optimization (3-4 weeks) - -### Week 1: Remove Node.js Backend - -**Tasks**: -- Verify all features migrated -- Update CI/CD pipelines -- Remove Node.js backend code -- Update deployment configurations - -### Week 2-3: Optimization - -**Tasks**: -1. **Database Optimization** - - Add missing indexes - - Optimize slow queries - - Set up connection pooling - -2. **Caching Strategy** - - Redis for frequently accessed data - - ETS for application-level cache - - CDN for static assets - -3. **Performance Tuning** - ```elixir - # config/prod.exs - config :codincod_api, CodincodApiWeb.Endpoint, - http: [ - port: 4000, - protocol_options: [max_connections: 16_384] - ] - ``` - -### Week 4: Documentation & Training - -**Tasks**: -- Update all documentation -- Team training on Elixir/Phoenix -- Runbook for operations -- Incident response procedures - -**Deliverables**: -- Node.js backend retired -- Optimized Elixir backend -- Complete documentation - ---- - -## Data Migration Strategy - -### MongoDB → PostgreSQL - -**Approach**: Dual-write during transition - -1. **Phase 1**: Read from MongoDB, write to both -2. **Phase 2**: Migrate historical data -3. **Phase 3**: Read from PostgreSQL, write to both -4. **Phase 4**: PostgreSQL only - -**Example Migration Script**: -```elixir -defmodule CodincodApi.Release.MigrateUsers do - def run do - # Read from MongoDB - {:ok, mongo} = Mongo.start_link(url: "mongodb://localhost:27017/codincod") - users = Mongo.find(mongo, "users", %{}) |> Enum.to_list() - - # Write to PostgreSQL - Enum.each(users, fn user -> - CodincodApi.Accounts.create_user(%{ - id: user["_id"], - username: user["username"], - email: user["email"], - password_hash: user["password"], - # ... - }) - end) - end -end -``` - ---- - -## Deployment Strategy - -### Parallel Deployment - -``` -┌─────────────┐ -│ Frontend │ (Svelte/SvelteKit) -│ (Caddy) │ -└─────┬───────┘ - │ - ├──────────┐ - │ │ -┌─────▼───┐ ┌──▼────────┐ -│ Node.js │ │ Elixir │ -│ Fastify │ │ Phoenix │ -│ MongoDB │ │PostgreSQL │ -└─────────┘ └───────────┘ -``` - -**Traffic Routing**: -``` -# Caddyfile -codincod.com { - # Route based on feature flags or user segments - @auth_elixir header X-Use-Elixir-Auth true - handle @auth_elixir { - reverse_proxy elixir_backend:4000 - } - - # Default to Node.js - reverse_proxy nodejs_backend:3000 -} -``` - ---- - -## Testing Strategy - -### Test Coverage Goals - -- **Unit Tests**: 80%+ coverage -- **Integration Tests**: All API endpoints -- **E2E Tests**: Critical user flows -- **Load Tests**: 1000+ concurrent users - -### Testing Tools - -```elixir -# test/codincod_api/accounts_test.exs -defmodule CodincodApi.AccountsTest do - use CodincodApi.DataCase - - alias CodincodApi.Accounts - - describe "users" do - test "create_user/1 with valid data creates a user" do - attrs = %{username: "test", email: "test@example.com", password: "password123"} - assert {:ok, user} = Accounts.create_user(attrs) - assert user.username == "test" - end - - test "create_user/1 with duplicate username returns error" do - attrs = %{username: "test", email: "test@example.com", password: "password123"} - Accounts.create_user(attrs) - assert {:error, changeset} = Accounts.create_user(attrs) - assert "has already been taken" in errors_on(changeset).username - end - end -end -``` - ---- - -## Rollback Plan - -### Per-Phase Rollback - -Each phase has independent rollback: - -1. **Feature Flag Disable**: Toggle feature flag to route back to Node.js -2. **Database Rollback**: Keep MongoDB read replica during transition -3. **Code Rollback**: Git tags for each phase -4. **Data Integrity**: Regular backups before each migration step - -### Emergency Rollback Procedure - -```bash -# 1. Disable feature flag -echo "VITE_USE_ELIXIR_AUTH=false" >> .env - -# 2. Scale down Elixir backend -kubectl scale deployment elixir-backend --replicas=0 - -# 3. Scale up Node.js backend -kubectl scale deployment nodejs-backend --replicas=5 - -# 4. Update load balancer -# Route 100% traffic back to Node.js -``` - ---- - -## Success Metrics - -### Performance - -- [ ] P50 latency < 50ms -- [ ] P95 latency < 200ms -- [ ] P99 latency < 500ms -- [ ] 99.9% uptime - -### Migration - -- [ ] 100% feature parity -- [ ] Zero data loss -- [ ] No downtime during migration -- [ ] < 5% user-reported issues - -### Development - -- [ ] CI/CD pipeline < 10 minutes -- [ ] Test suite < 5 minutes -- [ ] Deploy time < 5 minutes - ---- - -## Timeline Summary - -| Phase | Duration | Description | -|-------|----------|-------------| -| 0. Preparation | 3-4 weeks | Setup, learning, planning | -| 1. Authentication | 4-6 weeks | User auth, JWT, session | -| 2. Puzzles | 4-5 weeks | CRUD, comments, voting | -| 3. Submissions | 3-4 weeks | Execution, stats | -| 4. Real-Time Games | 6-8 weeks | WebSockets, channels | -| 5. Moderation | 2-3 weeks | Admin, reports, bans | -| 6. Cleanup | 3-4 weeks | Optimization, docs | - -**Total**: ~25-34 weeks (6-8 months) - ---- - -## Risk Mitigation - -### Technical Risks - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Data loss during migration | Medium | Critical | Comprehensive backups, validation scripts, dual-write period | -| Performance regression | Medium | High | Load testing, gradual rollout, monitoring | -| WebSocket incompatibility | Low | High | Thorough testing, protocol versioning | -| Team learning curve | High | Medium | Training, pair programming, code reviews | - -### Business Risks - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Extended downtime | Low | Critical | Parallel deployment, feature flags | -| User dissatisfaction | Medium | High | Gradual rollout, feedback loops | -| Budget overrun | Medium | Medium | Phased approach, regular reviews | - ---- - -## Post-Migration Benefits - -### Performance - -- **10x faster queries**: PostgreSQL vs MongoDB for relational data -- **Better concurrency**: BEAM VM handles millions of processes -- **Lower latency**: Native WebSocket support - -### Development - -- **Type safety**: Dialyzer static analysis -- **Hot code reloading**: Zero-downtime deployments -- **Better testing**: ExUnit, property-based testing -- **Cleaner code**: Functional, immutable, pattern matching - -### Operations - -- **Observability**: Built-in telemetry and metrics -- **Fault tolerance**: OTP supervision trees -- **Scalability**: Distributed Elixir clusters -- **Resource efficiency**: Lower memory footprint - ---- - -## Conclusion - -This migration is ambitious but achievable with careful planning and execution. The phased approach allows for continuous delivery of value while reducing risk. The end result will be a more performant, maintainable, and scalable backend that positions CodinCod for future growth. - -**Next Steps**: -1. Review and approve this roadmap -2. Allocate team resources -3. Begin Phase 0: Preparation -4. Set up regular migration status meetings -5. Create detailed project plan with milestones - ---- - -## Appendices - -### A. Useful Elixir Libraries - -- **Phoenix**: Web framework -- **Ecto**: Database wrapper and ORM -- **Guardian**: JWT authentication -- **Oban**: Background job processing -- **Tesla/Finch**: HTTP clients -- **Hammer**: Rate limiting -- **Cachex**: Caching -- **ExUnit**: Testing framework -- **Credo**: Code analysis -- **Dialyzer**: Static type checking - -### B. Learning Resources - -- [Elixir Official Guides](https://elixir-lang.org/getting-started/introduction.html) -- [Phoenix Framework Guide](https://hexdocs.pm/phoenix/overview.html) -- [Programming Phoenix](https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/) -- [Designing Elixir Systems with OTP](https://pragprog.com/titles/jgotp/designing-elixir-systems-with-otp/) -- [Real-Time Phoenix](https://pragprog.com/titles/sbsockets/real-time-phoenix/) - -### C. Team Training Plan - -**Week 1-2**: Elixir Fundamentals -- Syntax and basic types -- Pattern matching -- Functions and modules -- Processes and message passing - -**Week 3-4**: Phoenix Framework -- Routing and controllers -- Contexts and business logic -- Ecto and database operations -- Testing with ExUnit - -**Week 5-6**: Advanced Topics -- OTP and GenServers -- Phoenix Channels -- Deployment and operations -- Performance optimization diff --git a/FEATURE_ROADMAP.md b/FEATURE_ROADMAP.md deleted file mode 100644 index 33d0ab0c..00000000 --- a/FEATURE_ROADMAP.md +++ /dev/null @@ -1,248 +0,0 @@ -# Feature Implementation Roadmap - -### 2. Private Games -**Status**: Not started -**Effort**: Small-Medium -**Impact**: High -**Description**: Allow users to create invite-only games -**Tasks**: -- Add `inviteCode` to Game model -- Generate unique codes for private games -- Add join-by-code UI -- Filter private games from public lobby - -**Implementation**: -```typescript -// Backend -should be generated in the waiting room and returned to the frontend to invite people - -// Frontend - -``` - ---- - -### 3. Reporting System -**Status**: Partially implemented (user bans exist) -**Effort**: Medium -**Impact**: High (user safety) -**Description**: Allow users to report inappropriate content/behavior -**Tasks**: -- Create Report model (reporter, reported, category, description, status) -- Add report button to user profiles, puzzles, comments -- Create admin moderation dashboard -- Implement automated escalation for repeat offenders - -**Existing**: -- User ban system already implemented -- Ban types (temporary, permanent) -- Check user ban middleware - -**New**: -```typescript -interface Report { - reporter: ObjectId; - reported: ObjectId; - category: "spam" | "harassment" | "cheating" | "inappropriate"; - description: string; - status: "pending" | "resolved" | "dismissed"; - evidence?: string[]; -} -``` - - ---- - -## 🟡 MEDIUM PRIORITY - -### 5. Ranked Matchmaking -**Status**: Mode exists, needs matchmaking -**Effort**: Large -**Impact**: High (engagement) -**Description**: Implement ELO/Glicko2 ranking system with skill-based matchmaking -**Tasks**: -- Implement Glicko2 rating algorithm -- Add rating, ratingDeviation, volatility to User model -- Create matchmaking queue system -- Match players by skill level -- Update ratings after games -- keep the puzzle difficulty in mind in the calculation, can't give beginners an almost impossible puzzle for them... - -**Recommended**: check out how lichess does their matchmaking - ---- - -### 6. Community Challenges -**Status**: Puzzle voting exists -**Effort**: Medium -**Impact**: Medium (engagement) -**Description**: User-created challenges with voting -**Tasks**: -- Already have puzzle creation & approval system -- Add challenge categories/tags -- Add automatic difficulty ratings, based on completion rates and puzzle metrics - -**Leverage existing**: -- Puzzle approval system -- User voting on puzzles -- Comment system - ---- - -### 4. User Blocking -**Status**: Not started -**Effort**: Small -**Impact**: Medium (user experience) -**Description**: Allow users to block others -**Tasks**: -- Add `blockedUsers` array to User model -- Create block/unblock endpoints -- block blocked users from joining a game created by the creator who blocked them -- Hide blocked users' comments/content - ---- - -### 7. General Chat -**Status**: Not started -**Effort**: Medium -**Impact**: Medium -**Description**: Global chat room for community -**Tasks**: -- Create ChatMessage model -- Implement WebSocket-based chat -- Add message history pagination -- Moderation tools (delete, timeout) -- Rate limiting - -**Reuse**: -- Existing WebSocket infrastructure -- ConnectionManager pattern - ---- - -### 8. Events System -**Status**: Not started -**Effort**: Large -**Impact**: High (engagement, but complex) -**Description**: Scheduled competitions with leaderboards -**Tasks**: -- Create Event model (type, startTime, endTime, puzzles, prizes) -- Event registration system -- Event-specific leaderboards -- Automated event scheduling -- Prize distribution - -**Event Types**: -- Daily challenges -- Weekly competitions -- Monthly tournaments -- Themed events (Python month, etc.) - ---- - -## 🔵 LOW PRIORITY - -### 9. Private Messages -**Status**: Not started -**Effort**: Medium -**Description**: DM system between users -**Tasks**: -- Create Conversation & Message models -- Message thread UI -- Real-time message delivery (WebSocket) -- Read receipts -- Message notifications - ---- - -### 10. Collaborative Puzzles -**Status**: Not started -**Effort**: Large (very complex) -**Description**: Real-time collaborative code editing -**Tasks**: -- Implement operational transformation or CRDT -- Shared code editor state -- Cursor positions for all users -- Team formation system -- Team scoring - -**Note**: Very complex, requires careful architecture - ---- - -### 11. Streaming Integration -**Status**: Not started -**Effort**: Medium -**Description**: Twitch/YouTube integration for streamers -**Tasks**: -- OAuth with streaming platforms -- Overlay widgets for streams -- Streamer mode (hide sensitive info) -- Stream chat integration - ---- - -## Implementation Recommendations - -### Immediate Next Steps (in order): - -1. **Custom Game UI** (1-2 hours) - - Quick win, backend already done - - High user value - -2. **Private Games** (3-4 hours) - - Small backend changes - - Frequently requested feature - -3. **Reporting System** (1-2 days) - - User safety is critical - - Foundation for healthy community - -4. **User Blocking** (3-4 hours) - - Complements reporting system - - Low complexity, high UX value - -5. **Ranked Matchmaking** (3-5 days) - - High engagement potential - - Requires careful testing - -### Technical Debt to Address: - -- Refactor remaining routes to use service layer -- Add comprehensive error handling -- Implement rate limiting on all endpoints -- Add WebSocket reconnection logic -- Create automated tests for game modes - -### Architecture Notes: - -- **Services First**: Always use service layer for DB operations -- **WebSocket Patterns**: Reuse existing ConnectionManager pattern -- **Type Safety**: Leverage Zod schemas from types library -- **Game Modes**: Use strategy pattern for new competitive modes -- **Lean Code**: Avoid over-engineering, iterate quickly - ---- - -## Estimated Timeline - -**Month 1**: -- Custom Game UI -- Private Games -- Reporting System -- User Blocking - -**Month 2**: -- Ranked Matchmaking -- General Chat -- Community Challenges improvements - -**Month 3**: -- Events System (MVP) -- Private Messages -- Platform polish - -**Long-term**: -- Collaborative Puzzles -- Streaming Integration -- Mobile app diff --git a/docker-compose.yml b/docker-compose.yml index 08245d02..a9103bf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,22 @@ services: + postgres: + image: postgres:16-alpine + container_name: codincod-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: codincod_dev + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + piston: image: ghcr.io/engineer-man/piston container_name: piston @@ -12,56 +30,51 @@ services: - /tmp:exec backend: - container_name: backend + container_name: codincod-backend restart: unless-stopped build: - context: . # Root directory - args: - - ORIGIN=${APP_ORIGIN:-https://codincod.com} - dockerfile: libs/backend/Dockerfile # Correct path + context: ./libs/backend/codincod_api + dockerfile: Dockerfile ports: - - "8888:8888" - # env_file: - # - ./libs/backend/.env # Load frontend environment variables + - "4000:4000" environment: - - NODE_ENV=production - - PISTON_URI=http://piston:2000 - - FRONTEND_URL=https://codincod.com - - FRONTEND_HOST=.codincod.com + - MIX_ENV=dev + - DATABASE_URL=ecto://postgres:postgres@postgres:5432/codincod_dev + - SECRET_KEY_BASE=your_secret_key_base_here_change_in_production + - PISTON_BASE_URL=http://piston:2000 + - PORT=4000 + - PHX_HOST=localhost depends_on: - - piston - # # optional for when you want to run mongodb locally - # - mongo + postgres: + condition: service_healthy + piston: + condition: service_started + volumes: + - ./libs/backend/codincod_api:/app + - backend_build:/app/_build + - backend_deps:/app/deps + command: sh -c "mix deps.get && mix ecto.create && mix ecto.migrate && mix phx.server" frontend: - container_name: frontend + container_name: codincod-frontend restart: unless-stopped build: context: . # Root directory dockerfile: libs/frontend/Dockerfile # Correct path args: - - VITE_BACKEND_URL=https://backend.codincod.com - - VITE_BACKEND_WEBSOCKET_MULTIPLAYER=wss://backend.codincod.com - - ORIGIN=https://codincod.com + - VITE_BACKEND_URL=http://localhost:4000 + - VITE_BACKEND_WEBSOCKET_MULTIPLAYER=ws://localhost:4000 + - ORIGIN=http://localhost:5173 ports: - "5173:5173" - # env_file: - # - ./libs/frontend/.env # Load frontend environment variables environment: - NODE_ENV=production - HOST=0.0.0.0 - PORT=5173 - - FRONTEND_HOST=.codincod.com depends_on: - backend -# # optional for when you want to run mongodb locally -# mongo: -# image: mongo:latest -# environment: -# MONGO_INITDB_ROOT_USERNAME: codincod-dev -# MONGO_INITDB_ROOT_PASSWORD: hunter2 -# volumes: -# - mongo_data:/data/db -# volumes: -# mongo_data: +volumes: + postgres_data: + backend_build: + backend_deps: diff --git a/libs/backend/.env.example b/libs/backend/.env.example index 951ce2a0..c96c7955 100644 --- a/libs/backend/.env.example +++ b/libs/backend/.env.example @@ -1,18 +1,128 @@ -NODE_ENV=development -FASTIFY_PORT=8888 -FASTIFY_HOST=0.0.0.0 +# =================================== +# Database Configuration +# =================================== + +# Local PostgreSQL (Docker) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/codincod_dev +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=codincod_dev +DATABASE_POOL_SIZE=10 + +# Cloud PostgreSQL (Production - example) +# DATABASE_URL=postgresql://user:pass@your-db.example.com:5432/codincod_prod?ssl=true +# DATABASE_SSL=true +# DATABASE_IPV6=false + +# Test Database +TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/codincod_test + +# =================================== +# MongoDB (Legacy - for migration) +# =================================== -PISTON_URI=http://localhost:2000 -MONGO_DB_NAME=codincod-development MONGO_URI=mongodb://codincod-dev:hunter2@localhost:27017 +MONGO_DB_NAME=codincod-development +MONGO_USERNAME=codincod-dev +MONGO_PASSWORD=hunter2 -REDIS_PASSWORD=your_redis_password_in_production_at_least_30_chars_long +# =================================== +# Phoenix Application +# =================================== +# Generate with: mix phx.gen.secret +SECRET_KEY_BASE=your_secret_key_base_here_at_least_64_chars_long_generate_with_mix_phx_gen_secret + +PHX_HOST=localhost +PHX_PORT=4000 +PHX_SERVER=true + +# URLs +FRONTEND_URL=http://localhost:5173 +BACKEND_URL=http://localhost:4000 + +# =================================== +# Authentication & Security +# =================================== + +# Bcrypt work factor (higher = more secure but slower) +BCRYPT_ROUNDS=12 + +# JWT Configuration JWT_SECRET=your_jwt_secret_here JWT_EXPIRY=7d +JWT_ISSUER=codincod_api + +# Session Configuration +SESSION_SIGNING_SALT=your_session_signing_salt_here +SESSION_ENCRYPTION_SALT=your_session_encryption_salt_here +SESSION_TTL_DAYS=14 +PASSWORD_RESET_TTL_HOURS=1 +EMAIL_CONFIRMATION_TTL_HOURS=24 -# These are only used when running mongo with `docker compose`, they should match user and password in MONGO_URI -CODINCOD_MONGODB_USERNAME=codincod-dev -CODINCOD_MONGODB_PASSWORD=hunter2 +# =================================== +# External Services +# =================================== + +# Piston API (Code Execution) +PISTON_URI=http://localhost:2000 -FRONTEND_URL=http://localhost:5173 \ No newline at end of file +# Redis (Caching & Rate Limiting) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis_password +REDIS_DATABASE=0 + +# =================================== +# CORS Configuration +# =================================== + +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# =================================== +# Email Configuration (Swoosh) +# =================================== + +# For development (using local adapter) +MAILER_ADAPTER=local + +# For production (example with SendGrid) +# MAILER_ADAPTER=sendgrid +# SENDGRID_API_KEY=your_sendgrid_api_key_here +# MAILER_FROM_EMAIL=noreply@codincod.com +# MAILER_FROM_NAME=CodinCod + +# =================================== +# Background Jobs (Oban) +# =================================== + +OBAN_ENABLED=true +OBAN_QUEUES_DEFAULT=10 +OBAN_QUEUES_MAILER=5 +OBAN_QUEUES_EVENTS=20 + +# =================================== +# Rate Limiting +# =================================== + +RATE_LIMIT_ENABLED=true +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_LOGIN_PER_HOUR=5 + +# =================================== +# Logging & Monitoring +# =================================== + +LOG_LEVEL=info + +# Sentry (optional - for production error tracking) +# SENTRY_DSN=https://your_sentry_dsn_here + +# =================================== +# Development & Testing +# =================================== + +MIX_ENV=dev +NODE_ENV=development diff --git a/libs/backend/.gitignore b/libs/backend/.gitignore deleted file mode 100644 index 6a7d6d8e..00000000 --- a/libs/backend/.gitignore +++ /dev/null @@ -1,130 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* \ No newline at end of file diff --git a/libs/backend/.npmrc b/libs/backend/.npmrc deleted file mode 100644 index b6f27f13..00000000 --- a/libs/backend/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/libs/backend/.prettierignore b/libs/backend/.prettierignore deleted file mode 100644 index cdcdad2c..00000000 --- a/libs/backend/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ - -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock diff --git a/libs/backend/.prettierrc b/libs/backend/.prettierrc deleted file mode 100644 index 6013ccb7..00000000 --- a/libs/backend/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "useTabs": true, - "singleQuote": false, - "trailingComma": "none", - "printWidth": 80, - "plugins": [] -} diff --git a/libs/backend/Dockerfile b/libs/backend/Dockerfile deleted file mode 100644 index 3c65ac2e..00000000 --- a/libs/backend/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM node:20 - -# Set working directory -WORKDIR /app - -# Copy root files and workspace configuration -COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ - -# Copy all package.json files for workspace packages -COPY libs/backend/package.json ./libs/backend/ -COPY libs/types/package.json ./libs/types/ - -# Install pnpm globally and all workspace dependencies -RUN npm install -g pnpm@10 -RUN pnpm install --filter backend... - -# Copy all necessary files for backend and types -COPY libs/backend ./libs/backend/ -COPY libs/types ./libs/types/ - -# Build the backend -WORKDIR /app/libs/types -RUN pnpm build - -WORKDIR /app/libs/backend -RUN pnpm build - -# Start the backend -CMD ["pnpm", "start"] \ No newline at end of file diff --git a/libs/backend/README.md b/libs/backend/README.md index ba9331fd..b40f5b97 100644 --- a/libs/backend/README.md +++ b/libs/backend/README.md @@ -1,62 +1,425 @@ -# Backend +# CodinCod Elixir API Backend -A Node.js backend API built with [Fastify](https://fastify.dev/), MongoDB, and TypeScript. This backend provides REST endpoints for a coding challenge platform. +Modern Elixir/Phoenix backend for the CodinCod coding puzzle platform. This is a complete rewrite of the Node.js/Fastify backend with improved performance, scalability, and real-time capabilities. -## Table of Contents +## Features -- [Architecture Overview](#architecture-overview) -- [Getting Started](#getting-started) -- [Environment Variables](#environment-variables) +- 🔐 **JWT Authentication** with Guardian +- 🎮 **Real-Time Multiplayer** with Phoenix Channels +- 🧩 **Puzzle Management** with advanced search and filtering +- 💻 **Code Execution** via Piston API integration +- 📊 **Leaderboards & Statistics** with ELO-style rankings +- 💬 **Real-Time Chat** in multiplayer games +- 🛡️ **Moderation Tools** for content review +- 📈 **Background Jobs** with Oban +- ⚡ **Rate Limiting** with Hammer +- 🗄️ **PostgreSQL** database with UUIDs -## Architecture Overview +## Tech Stack -### Key Technologies +- **Elixir** 1.15+ +- **Phoenix** 1.8+ (API-only) +- **PostgreSQL** 16+ +- **Ecto** 3.13+ (ORM) +- **Guardian** 2.3+ (JWT) +- **Oban** 2.18+ (Background jobs) +- **Phoenix Channels** (WebSockets) -- **Node.js 20+** - Runtime environment -- **TypeScript** - Type-safe development -- **Fastify** - Web framework -- **Mongoose** - MongoDB ODM -- **Zod** - Schema validation (shared via `types` package) -- **Piston** - Code execution service +## Prerequisites -## Getting Started +- Elixir 1.15 or higher +- Erlang/OTP 26 or higher +- PostgreSQL 16 or higher +- Docker & Docker Compose (optional) -### Prerequisites +## Installation -1. **Node.js 20+** and **pnpm** installed -2. **MongoDB** running locally or accessible via connection string -3. **Piston service** running (for code execution features and seeding the database) +### 1. Install Dependencies -### Setup Steps +```bash +cd libs/elixir-backend/codincod_api +mix deps.get +``` +### 2. Start Infrastructure (Docker) -1. **Install dependencies** using pnpm (workspace-aware): +```bash +cd libs/elixir-backend +docker compose up -d postgres piston redis +``` - ```bash - pnpm install - ``` +> The compose file also provides an `api` service. Run `docker compose up --build api` +> if you prefer the Phoenix server to run inside Docker instead of your local Elixir toolchain. -2. **Set up environment variables** (see [Environment Variables](#environment-variables)) +### 3. Configure Environment -3. **Run database migrations** (creates collections and populates initial data): +```bash +cp .env.example .env +# Edit .env with your configuration +``` - ```bash - pnpm migrate - ``` +### 4. Create and Migrate Database -4. **Seed test data** (optional, for development): +```bash +mix ecto.create +mix ecto.migrate +``` - ```bash - pnpm seed - ``` +### 5. Seed Database (Optional) -5. **Start development server**: +```bash +mix run priv/repo/seeds.exs +``` - ```bash - pnpm dev - ``` +### 6. Start Phoenix Server - The API will be available at `http://localhost:` (default: 8888) +```bash +mix phx.server +``` + +Or inside IEx: + +```bash +iex -S mix phx.server +``` + +The API will be available at http://localhost:4000 + +## Development + +### Running Tests + +```bash +# Run all tests +mix test + +# Run with coverage +mix test --cover + +# Watch mode +mix test.watch +``` + +### Code Quality + +```bash +# Linting with Credo +mix credo + +# Static analysis with Dialyzer +mix dialyzer + +# Format code +mix format +``` + +### Database + +```bash +# Create migration +mix ecto.gen.migration migration_name + +# Run migrations +mix ecto.migrate + +# Rollback +mix ecto.rollback + +# Reset database +mix ecto.reset +``` + +### Generating Code + +```bash +# Generate context with schema +mix phx.gen.context Accounts User users email:string username:string + +# Generate schema only +mix phx.gen.schema Accounts.User users email:string + +# Generate JSON API +mix phx.gen.json Accounts User users email:string +``` + +## Project Structure + +``` +codincod_api/ +├── config/ # Application configuration +│ ├── config.exs # Base configuration +│ ├── dev.exs # Development config +│ ├── test.exs # Test config +│ ├── prod.exs # Production config +│ └── runtime.exs # Runtime config (env vars) +├── lib/ +│ ├── codincod_api/ # Core application +│ │ ├── accounts/ # User authentication & management +│ │ ├── puzzles/ # Puzzle system +│ │ ├── submissions/ # Code submissions +│ │ ├── games/ # Multiplayer games +│ │ ├── chat/ # Real-time chat +│ │ ├── comments/ # Comments & voting +│ │ ├── moderation/ # Content moderation +│ │ ├── metrics/ # Statistics & leaderboards +│ │ ├── languages/ # Programming languages +│ │ └── repo.ex # Database repository +│ └── codincod_api_web/ # Web interface +│ ├── channels/ # WebSocket channels +│ ├── controllers/ # HTTP controllers +│ ├── auth/ # Authentication (Guardian) +│ ├── plugs/ # Custom plugs +│ ├── views/ # JSON views +│ └── router.ex # Route definitions +├── priv/ +│ ├── repo/ +│ │ ├── migrations/ # Database migrations +│ │ └── seeds.exs # Seed data +│ └── static/ # Static files +├── test/ # Tests +│ ├── codincod_api/ # Context tests +│ ├── codincod_api_web/ # Controller tests +│ └── support/ # Test helpers & factories +└── mix.exs # Dependencies & config +``` + +## API Documentation + +### Authentication Endpoints + +``` +POST /api/register - Register new user +POST /api/login - Login user +POST /api/logout - Logout user +POST /api/refresh - Refresh JWT token +GET /api/user - Get current user +``` + +### User Endpoints + +``` +GET /api/users/:id - Get user profile +PUT /api/users/:id - Update user +GET /api/users/:username/activity +GET /api/users/:username/puzzles +``` + +### Puzzle Endpoints + +``` +GET /api/puzzle - List puzzles +POST /api/puzzle - Create puzzle +GET /api/puzzle/:id - Get puzzle +PUT /api/puzzle/:id - Update puzzle +DELETE /api/puzzle/:id - Delete puzzle +GET /api/puzzle/:id/comments +POST /api/puzzle/:id/comments +``` + +### Submission Endpoints + +``` +GET /api/submission - List user submissions +POST /api/submission - Submit code +GET /api/submission/:id - Get submission +``` + +### Game Endpoints + +``` +WebSocket: /socket/waiting_room - Waiting room lobby +WebSocket: /socket/game/:id - Game room +``` + +## WebSocket Events + +### Waiting Room Channel + +```elixir +# Join waiting room +Phoenix.Channel.join("waiting_room:lobby") + +# Host a room +push("room:host", %{options: %{visibility: "public"}}) + +# Join a room +push("room:join", %{room_id: "abc123"}) + +# Events received +handle_in("rooms:overview", payload) +handle_in("game:start", %{game_url: url}) +``` + +### Game Channel + +```elixir +# Join game +Phoenix.Channel.join("game:#{game_id}") + +# Submit code +push("game:submit", %{code: code, language: "python"}) + +# Send chat message +push("chat:message", %{message: "Hello!"}) + +# Events received +handle_in("game:update", game_state) +handle_in("player:submitted", %{user: user}) +handle_in("chat:message", message) +``` ## Environment Variables -Create a `.env` file in `libs/backend/` based on `.env.example`: +See `.env.example` for all available configuration options. + +Key variables: + +```bash +DATABASE_URL=postgresql://user:pass@localhost/db +SECRET_KEY_BASE=generate_with_mix_phx_gen_secret +JWT_SECRET=your_jwt_secret +PISTON_URI=http://localhost:2000 +FRONTEND_URL=http://localhost:5173 +``` + +## Background Jobs + +The application uses Oban for background job processing: + +```elixir +# Queue a code execution job +%{submission_id: submission.id} +|> CodincodApi.Workers.ExecuteSubmission.new() +|> Oban.insert() + +# Queue a statistics update +%{user_id: user.id} +|> CodincodApi.Workers.UpdateStatistics.new() +|> Oban.insert() +``` + +## Deployment + +### Using Docker + +```bash +# Build image +docker build -t codincod-api . + +# Run container +docker run -p 4000:4000 \ + -e DATABASE_URL=... \ + -e SECRET_KEY_BASE=... \ + codincod-api +``` + +### Using Mix Release + +```bash +# Build release +MIX_ENV=prod mix release + +# Run release +_build/prod/rel/codincod_api/bin/codincod_api start +``` + +## Monitoring + +Access Phoenix LiveDashboard at: http://localhost:4000/dev/dashboard + +Metrics include: +- Request rates and latencies +- Database query performance +- Background job statistics +- WebSocket connection counts +- System resource usage + +## Migration from MongoDB + +To migrate data from the existing MongoDB database: + +```bash +# Run full migration +mix migrate_mongo + +# Migrate specific entities +mix migrate_mongo --only users +mix migrate_mongo --only puzzles + +# Validate migration +mix migrate_mongo --validate +``` + +## TypeScript Type Generation + +The Elixir backend publishes an OpenAPI document that feeds the shared `libs/types` package. + +```bash +# From libs/elixir-backend/codincod_api +mix codincod.gen_openapi_spec + +# From repo root (requires pnpm) +pnpm --filter types run openapi:types +``` + +The second command regenerates `libs/types/src/generated/elixir-openapi.ts`, keeping the frontend +contracts in sync with the Phoenix controllers. Run these steps after adding or changing endpoints +or schemas. + +## Troubleshooting + +### Database Connection Issues + +```bash +# Check if PostgreSQL is running +docker compose ps + +docker-compose restart postgres +# Restart PostgreSQL +docker compose restart postgres + +docker-compose logs postgres +# Check logs +docker compose logs postgres +``` + +### Compilation Errors + +```bash +# Clean and recompile +mix clean +mix compile +``` + +### Port Already in Use + +```bash +# Kill process on port 4000 +lsof -ti:4000 | xargs kill -9 +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## Resources + +- [Phoenix Framework](https://phoenixframework.org/) +- [Ecto Documentation](https://hexdocs.pm/ecto/) +- [Guardian Documentation](https://hexdocs.pm/guardian/) +- [Oban Documentation](https://hexdocs.pm/oban/) +- [Phoenix Channels Guide](https://hexdocs.pm/phoenix/channels.html) + +## License + +Copyright © 2024 CodinCod + +## Support + +For issues and questions: +- Open an issue on GitHub +- Check the [Migration Guide](./MIGRATION_GUIDE.md) +- Consult the Phoenix documentation diff --git a/libs/backend/codincod_api/.dockerignore b/libs/backend/codincod_api/.dockerignore new file mode 100644 index 00000000..2cfdc246 --- /dev/null +++ b/libs/backend/codincod_api/.dockerignore @@ -0,0 +1,5 @@ +_build +cover +deps +node_modules +*.ez diff --git a/libs/backend/codincod_api/.env.example b/libs/backend/codincod_api/.env.example new file mode 100644 index 00000000..951ce2a0 --- /dev/null +++ b/libs/backend/codincod_api/.env.example @@ -0,0 +1,18 @@ +NODE_ENV=development +FASTIFY_PORT=8888 +FASTIFY_HOST=0.0.0.0 + +PISTON_URI=http://localhost:2000 +MONGO_DB_NAME=codincod-development +MONGO_URI=mongodb://codincod-dev:hunter2@localhost:27017 + +REDIS_PASSWORD=your_redis_password_in_production_at_least_30_chars_long + +JWT_SECRET=your_jwt_secret_here +JWT_EXPIRY=7d + +# These are only used when running mongo with `docker compose`, they should match user and password in MONGO_URI +CODINCOD_MONGODB_USERNAME=codincod-dev +CODINCOD_MONGODB_PASSWORD=hunter2 + +FRONTEND_URL=http://localhost:5173 \ No newline at end of file diff --git a/libs/backend/codincod_api/.formatter.exs b/libs/backend/codincod_api/.formatter.exs new file mode 100644 index 00000000..5971023f --- /dev/null +++ b/libs/backend/codincod_api/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] +] diff --git a/libs/backend/codincod_api/.gitignore b/libs/backend/codincod_api/.gitignore new file mode 100644 index 00000000..fd35d612 --- /dev/null +++ b/libs/backend/codincod_api/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +codincod_api-*.tar + diff --git a/libs/backend/codincod_api/AGENTS.md b/libs/backend/codincod_api/AGENTS.md new file mode 100644 index 00000000..f96a4024 --- /dev/null +++ b/libs/backend/codincod_api/AGENTS.md @@ -0,0 +1,99 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct + + + \ No newline at end of file diff --git a/libs/backend/codincod_api/Dockerfile b/libs/backend/codincod_api/Dockerfile new file mode 100644 index 00000000..ad5704f2 --- /dev/null +++ b/libs/backend/codincod_api/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM hexpm/elixir:1.16.2-erlang-26.2.2-debian-bullseye-20240222 + +ENV LANG=C.UTF-8 \ + MIX_ENV=dev \ + HOME=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + inotify-tools \ + git \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +RUN mix local.hex --force \ + && mix local.rebar --force + +COPY mix.exs mix.lock ./ +COPY config config + +RUN mix deps.get + +CMD ["sh", "-c", "mix deps.get && mix ecto.create && mix ecto.migrate && mix phx.server"] diff --git a/libs/backend/codincod_api/README.md b/libs/backend/codincod_api/README.md new file mode 100644 index 00000000..269273c0 --- /dev/null +++ b/libs/backend/codincod_api/README.md @@ -0,0 +1,18 @@ +# CodincodApi + +To start your Phoenix server: + +* Run `mix setup` to install and setup dependencies +* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + +* Official website: https://www.phoenixframework.org/ +* Guides: https://hexdocs.pm/phoenix/overview.html +* Docs: https://hexdocs.pm/phoenix +* Forum: https://elixirforum.com/c/phoenix-forum +* Source: https://github.com/phoenixframework/phoenix diff --git a/libs/backend/codincod_api/config/config.exs b/libs/backend/codincod_api/config/config.exs new file mode 100644 index 00000000..276b6266 --- /dev/null +++ b/libs/backend/codincod_api/config/config.exs @@ -0,0 +1,76 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :codincod_api, + ecto_repos: [CodincodApi.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true] + +# Configures the endpoint +config :codincod_api, CodincodApiWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [json: CodincodApiWeb.ErrorJSON], + layout: false + ], + pubsub_server: CodincodApi.PubSub, + live_view: [signing_salt: "H/Z/XSwb"] + +# Configures the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :codincod_api, CodincodApi.Mailer, adapter: Swoosh.Adapters.Local + +# Configure Guardian for JWT authentication +config :codincod_api, CodincodApiWeb.Auth.Guardian, + issuer: "codincod_api", + secret_key: "your_guardian_secret_key_here_change_in_runtime" + +# Password hashing configuration +config :codincod_api, :password_adapter, Pbkdf2 + +# Runtime environment hints +config :codincod_api, :runtime_env, config_env() + +# Authentication cookie defaults +config :codincod_api, :auth_cookie, + name: "token", + max_age: 7 * 24 * 60 * 60 + +# Default Piston client implementation +config :codincod_api, :piston_client, CodincodApi.Piston.Client + +# Configure Oban for background jobs +config :codincod_api, Oban, + engine: Oban.Engines.Basic, + queues: [default: 10, mailer: 5, events: 20], + repo: CodincodApi.Repo + +# Configure Tesla HTTP client +config :tesla, adapter: Tesla.Adapter.Finch + +# Configure Hammer for rate limiting +config :hammer, + backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} + +# Configures Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id, :user_id, :remote_ip] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/libs/backend/codincod_api/config/dev.exs b/libs/backend/codincod_api/config/dev.exs new file mode 100644 index 00000000..6532b9a2 --- /dev/null +++ b/libs/backend/codincod_api/config/dev.exs @@ -0,0 +1,46 @@ +import Config + +# Configure your database +config :codincod_api, CodincodApi.Repo, + username: System.get_env("POSTGRES_USER") || "postgres", + password: System.get_env("POSTGRES_PASSWORD") || "postgres", + hostname: System.get_env("POSTGRES_HOST") || "localhost", + database: System.get_env("POSTGRES_DB") || "codincod_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: String.to_integer(System.get_env("DATABASE_POOL_SIZE") || "10") + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :codincod_api, CodincodApiWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PHX_PORT") || "4000")], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "zl1WcfqPspXQdJKM7z7g5tW6586do4B+9RvPvdRDCft9yH9MEQ9F+AzeBczOiz3x", + watchers: [] + +# Enable dev routes for dashboard and mailbox +config :codincod_api, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false + +# PBKDF2 lower cost for development +config :pbkdf2_elixir, :rounds, 16_000 diff --git a/libs/backend/codincod_api/config/prod.exs b/libs/backend/codincod_api/config/prod.exs new file mode 100644 index 00000000..30c73506 --- /dev/null +++ b/libs/backend/codincod_api/config/prod.exs @@ -0,0 +1,13 @@ +import Config + +# Configures Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Req + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/libs/backend/codincod_api/config/runtime.exs b/libs/backend/codincod_api/config/runtime.exs new file mode 100644 index 00000000..e003089f --- /dev/null +++ b/libs/backend/codincod_api/config/runtime.exs @@ -0,0 +1,94 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/codincod_api start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :codincod_api, CodincodApiWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :codincod_api, CodincodApi.Repo, + ssl: System.get_env("DATABASE_SSL") == "true", + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :codincod_api, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :codincod_api, CodincodApiWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # Guardian JWT configuration + config :codincod_api, CodincodApiWeb.Auth.Guardian, + issuer: System.get_env("JWT_ISSUER") || "codincod_api", + secret_key: System.get_env("JWT_SECRET") || secret_key_base + + # Piston API configuration + config :codincod_api, :piston, base_url: System.get_env("PISTON_URI") || "http://localhost:2000" + + # CORS configuration + config :cors_plug, + origin: String.split(System.get_env("CORS_ALLOWED_ORIGINS") || "http://localhost:5173", ",") + + # Mailer configuration + mailer_adapter = System.get_env("MAILER_ADAPTER") || "local" + + mailer_module = + case mailer_adapter do + "sendgrid" -> Swoosh.Adapters.Sendgrid + "mailgun" -> Swoosh.Adapters.Mailgun + "smtp" -> Swoosh.Adapters.SMTP + _ -> Swoosh.Adapters.Local + end + + config :codincod_api, CodincodApi.Mailer, adapter: mailer_module + + # Rate limiting configuration + if System.get_env("RATE_LIMIT_ENABLED") == "true" do + config :hammer, + backend: + {Hammer.Backend.ETS, + [ + expiry_ms: 60_000 * 60 * 4, + cleanup_interval_ms: 60_000 * 10 + ]} + end +end diff --git a/libs/backend/codincod_api/config/test.exs b/libs/backend/codincod_api/config/test.exs new file mode 100644 index 00000000..5381ef9a --- /dev/null +++ b/libs/backend/codincod_api/config/test.exs @@ -0,0 +1,42 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +test_db = System.get_env("POSTGRES_DB") || "codincod_api_test" +test_partition = System.get_env("MIX_TEST_PARTITION") + +config :codincod_api, CodincodApi.Repo, + username: System.get_env("POSTGRES_USER") || "postgres", + password: System.get_env("POSTGRES_PASSWORD") || "postgres", + hostname: System.get_env("POSTGRES_HOST") || "localhost", + database: test_db <> (test_partition || ""), + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :codincod_api, CodincodApiWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "c7sts8bQHiU24YfV7shYKABl1vrkugz+Hc2rtgp6AzEWLPCgxinjIGiNG/dVHT0w", + server: false + +# In test we don't send emails +config :codincod_api, CodincodApi.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Use in-memory piston mock for tests +config :codincod_api, :piston_client, CodincodApi.Piston.Mock + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# PBKDF2 minimal cost for tests +config :pbkdf2_elixir, :rounds, 1 diff --git a/libs/backend/codincod_api/cookies.txt b/libs/backend/codincod_api/cookies.txt new file mode 100644 index 00000000..c31d9899 --- /dev/null +++ b/libs/backend/codincod_api/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/libs/backend/codincod_api/lib/codincod_api.ex b/libs/backend/codincod_api/lib/codincod_api.ex new file mode 100644 index 00000000..f3714ff9 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api.ex @@ -0,0 +1,9 @@ +defmodule CodincodApi do + @moduledoc """ + CodincodApi keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts.ex b/libs/backend/codincod_api/lib/codincod_api/accounts.ex new file mode 100644 index 00000000..6829208c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts.ex @@ -0,0 +1,287 @@ +defmodule CodincodApi.Accounts do + @moduledoc """ + Accounts context responsible for user management, authentication and preferences. + + This module is the Elixir counterpart for the Node services defined in + `libs/backend/src/services/user.service.ts` and the login/register routes. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Accounts.{User, UserBan, Preference, Password, PasswordReset, Email} + alias CodincodApi.Mailer + + @type user_params :: map() + + ## Retrieval ----------------------------------------------------------------- + + @spec get_user!(Ecto.UUID.t()) :: User.t() + def get_user!(id) do + Repo.get!(User, id) + end + + @spec get_user(Ecto.UUID.t()) :: User.t() | nil + def get_user(id), do: Repo.get(User, id) + + @spec get_user_by_username(String.t()) :: User.t() | nil + def get_user_by_username(username) when is_binary(username) do + Repo.get_by(User, username: username) + end + + @spec get_user_with_preferences(Ecto.UUID.t()) :: User.t() | nil + def get_user_with_preferences(id) do + User + |> preload(:preferences) + |> Repo.get(id) + end + + ## Registration & profile ---------------------------------------------------- + + @spec register_user(user_params()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @spec update_profile(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_profile(%User{} = user, attrs) do + user + |> User.profile_changeset(attrs) + |> Repo.update() + end + + @spec change_user_profile(User.t(), map()) :: Ecto.Changeset.t() + def change_user_profile(%User{} = user, attrs \\ %{}) do + User.profile_changeset(user, attrs) + end + + ## Preferences --------------------------------------------------------------- + + @spec upsert_preferences(User.t(), map()) :: + {:ok, Preference.t()} | {:error, Ecto.Changeset.t()} + def upsert_preferences(%User{id: user_id}, attrs) do + preference = Repo.get_by(Preference, user_id: user_id) || %Preference{user_id: user_id} + + preference + |> Preference.changeset(Map.put(attrs, :user_id, user_id)) + |> Repo.insert_or_update() + end + + @spec get_preferences(User.t()) :: Preference.t() | nil + def get_preferences(%User{id: user_id}) do + Repo.get_by(Preference, user_id: user_id) + end + + @spec delete_preferences(User.t()) :: :ok | {:error, :not_found | Ecto.Changeset.t()} + def delete_preferences(%User{id: user_id}) do + case Repo.get_by(Preference, user_id: user_id) do + nil -> + {:error, :not_found} + + preference -> + case Repo.delete(preference) do + {:ok, _} -> :ok + {:error, changeset} -> {:error, changeset} + end + end + end + + ## Authentication ------------------------------------------------------------ + + @spec authenticate(String.t(), String.t()) :: + {:ok, User.t()} | {:error, :invalid_credentials | :banned} + def authenticate(identifier, password) when is_binary(identifier) and is_binary(password) do + query = + from u in User, + where: ilike(u.email, ^identifier) or u.username == ^identifier, + preload: [:current_ban] + + with %User{} = user <- Repo.one(query), + true <- Password.verify?(password, user.password_hash) do + if active_ban?(user) do + {:error, :banned} + else + {:ok, user} + end + else + _ -> {:error, :invalid_credentials} + end + end + + defp active_ban?(%User{current_ban: nil}), do: false + defp active_ban?(%User{current_ban: %UserBan{expires_at: nil}}), do: true + + defp active_ban?(%User{current_ban: %UserBan{expires_at: expires_at}}) do + DateTime.compare(expires_at, DateTime.utc_now()) == :gt + end + + ## Ban management ------------------------------------------------------------ + + @spec ban_user(User.t(), map()) :: {:ok, UserBan.t()} | {:error, Ecto.Changeset.t()} + def ban_user(%User{id: user_id}, attrs) do + with {:ok, ban} <- + %UserBan{user_id: user_id} + |> UserBan.changeset(attrs) + |> Repo.insert() do + Repo.update_all(from(u in User, where: u.id == ^user_id), + set: [current_ban_id: ban.id], + inc: [ban_count: 1] + ) + + {:ok, ban} + end + end + + @spec lift_ban(UserBan.t()) :: :ok | {:error, term()} + def lift_ban(%UserBan{id: id, user_id: user_id} = ban) do + now = DateTime.utc_now() + + case Repo.transaction(fn -> + Repo.update!( + Ecto.Changeset.change(ban, %{ + expires_at: ban.expires_at || now, + metadata: Map.put(ban.metadata || %{}, "lifted_at", now) + }) + ) + + Repo.update_all( + from(u in User, where: u.id == ^user_id and u.current_ban_id == ^id), + set: [current_ban_id: nil] + ) + + :ok + end) do + {:ok, :ok} -> :ok + {:error, reason} -> {:error, reason} + end + end + + ## Helpers ------------------------------------------------------------------- + + @spec change_user(User.t(), map()) :: Ecto.Changeset.t() + def change_user(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs) + end + + @doc """ + Returns true when no existing user (case-insensitive) owns the given username. + """ + @spec username_available?(String.t()) :: boolean() + def username_available?(username) when is_binary(username) do + normalized = username |> String.trim() |> String.downcase() + + if normalized == "" do + false + else + query = + from u in User, + select: 1, + where: fragment("lower(?) = ?", u.username, ^normalized), + limit: 1 + + Repo.one(query) == nil + end + end + + def username_available?(_), do: false + + ## Password Reset ------------------------------------------------------------ + + @doc """ + Initiates a password reset by creating a token and sending email. + """ + @spec request_password_reset(String.t(), String.t()) :: + {:ok, PasswordReset.t()} | {:error, :user_not_found | term()} + def request_password_reset(email, base_url) when is_binary(email) do + with %User{} = user <- Repo.get_by(User, email: String.downcase(email)), + token <- generate_secure_token(), + expires_at <- DateTime.add(DateTime.utc_now(), 3600, :second), + {:ok, reset} <- create_password_reset(user.id, token, expires_at), + reset_url <- build_reset_url(base_url, token), + email <- Email.password_reset_email(user, reset_url), + {:ok, _result} <- Mailer.deliver(email) do + {:ok, reset} + else + nil -> {:error, :user_not_found} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Validates and consumes a password reset token, updating the user's password. + """ + @spec reset_password_with_token(String.t(), String.t()) :: + {:ok, User.t()} | {:error, :invalid_token | :expired_token | Ecto.Changeset.t()} + def reset_password_with_token(token, new_password) when is_binary(token) do + now = DateTime.utc_now() + + with %PasswordReset{} = reset <- Repo.get_by(PasswordReset, token: token), + true <- is_nil(reset.used_at) || {:error, :invalid_token}, + :gt <- DateTime.compare(reset.expires_at, now) || {:error, :expired_token}, + %User{} = user <- Repo.get(User, reset.user_id), + {:ok, updated_user} <- update_password(user, new_password), + {:ok, _used_reset} <- + reset |> PasswordReset.mark_as_used() |> Repo.update() do + {:ok, updated_user} + else + nil -> {:error, :invalid_token} + {:error, reason} -> {:error, reason} + _ -> {:error, :invalid_token} + end + end + + defp create_password_reset(user_id, token, expires_at) do + %PasswordReset{} + |> PasswordReset.create_changeset(%{ + user_id: user_id, + token: token, + expires_at: expires_at + }) + |> Repo.insert() + end + + defp update_password(%User{} = user, new_password) do + {:ok, password_hash} = Password.hash(new_password) + + user + |> Ecto.Changeset.change(%{password_hash: password_hash}) + |> Repo.update() + end + + defp generate_secure_token do + :crypto.strong_rand_bytes(32) + |> Base.url_encode64(padding: false) + end + + defp build_reset_url(base_url, token) do + "#{base_url}/reset-password?token=#{token}" + end + + @doc """ + Fetches a user by ID, returning {:ok, user} or {:error, :not_found}. + """ + @spec fetch_user(Ecto.UUID.t()) :: {:ok, User.t()} | {:error, :not_found} + def fetch_user(user_id) do + case get_user(user_id) do + nil -> {:error, :not_found} + user -> {:ok, user} + end + end + + @doc """ + Removes the active ban for a user by calling lift_ban. + """ + @spec unban_user(User.t()) :: {:ok, User.t()} | {:error, :no_active_ban} + def unban_user(%User{current_ban_id: nil}), do: {:error, :no_active_ban} + + def unban_user(%User{current_ban_id: ban_id} = user) when not is_nil(ban_id) do + ban = Repo.get!(UserBan, ban_id) + + case lift_ban(ban) do + :ok -> {:ok, Repo.preload(user, :current_ban, force: true)} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/email.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/email.ex new file mode 100644 index 00000000..46a6f939 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/email.ex @@ -0,0 +1,49 @@ +defmodule CodincodApi.Accounts.Email do + @moduledoc """ + Constructs email messages for account actions like password resets. + """ + + import Swoosh.Email + + alias CodincodApi.Accounts.User + + @from_email Application.compile_env(:codincod_api, :from_email, "noreply@codincod.com") + + @doc """ + Builds password reset email with reset link. + """ + @spec password_reset_email(User.t(), String.t()) :: Swoosh.Email.t() + def password_reset_email(%User{email: email, username: username}, reset_url) do + new() + |> to({username, email}) + |> from({"CodinCod", @from_email}) + |> subject("Password Reset Request") + |> html_body(""" +

Password Reset

+

Hello #{username},

+

You requested a password reset for your CodinCod account.

+

Click the link below to reset your password:

+

Reset Password

+

This link will expire in 1 hour.

+

If you did not request this reset, please ignore this email.

+

Thanks,
The CodinCod Team

+ """) + |> text_body(""" + Password Reset + + Hello #{username}, + + You requested a password reset for your CodinCod account. + + Click the link below to reset your password: + #{reset_url} + + This link will expire in 1 hour. + + If you did not request this reset, please ignore this email. + + Thanks, + The CodinCod Team + """) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/password.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/password.ex new file mode 100644 index 00000000..a2f84e5c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/password.ex @@ -0,0 +1,82 @@ +defmodule CodincodApi.Accounts.Password do + @moduledoc """ + Centralised password hashing and verification utilities. + + Wraps the configured password adapter so we can mock or swap hashing + algorithms without touching the rest of the codebase. Defaults to + `Pbkdf2` for improved Windows compatibility while still allowing an + optional legacy adapter (for example, `Bcrypt`) to be configured during + the data migration window. + """ + + @type hash :: String.t() + + @spec hash(String.t()) :: {:ok, hash()} | {:error, String.t()} + def hash(password) when is_binary(password) do + adapter = adapter_module() + + with :ok <- ensure_adapter_loaded(adapter) do + {:ok, adapter.hash_pwd_salt(password)} + else + {:error, reason} -> {:error, reason} + end + rescue + error -> {:error, Exception.message(error)} + end + + @spec verify?(String.t(), hash()) :: boolean() + def verify?(password, hash) when is_binary(password) and is_binary(hash) do + adapter = pick_adapter(hash) + + case ensure_adapter_loaded(adapter) do + :ok -> adapter.verify_pass(password, hash) + {:error, _} -> false + end + rescue + _ -> false + end + + @spec needs_rehash?(hash()) :: boolean() + def needs_rehash?(hash) when is_binary(hash) do + adapter = pick_adapter(hash) + + case ensure_adapter_loaded(adapter) do + :ok -> adapter.needs_rehash?(hash) + {:error, _} -> true + end + rescue + _ -> true + end + + defp ensure_adapter_loaded(nil) do + {:error, "no password adapter configured"} + end + + defp ensure_adapter_loaded(module) do + if Code.ensure_loaded?(module) and + function_exported?(module, :hash_pwd_salt, 1) and + function_exported?(module, :verify_pass, 2) do + :ok + else + {:error, "password adapter #{inspect(module)} is not available"} + end + end + + defp pick_adapter(hash) do + cond do + pbkdf2_hash?(hash) -> adapter_module() + legacy_adapter = legacy_adapter_module() -> legacy_adapter + true -> adapter_module() + end + end + + defp pbkdf2_hash?(hash), do: String.starts_with?(hash, "$pbkdf2-") + + defp adapter_module do + Application.get_env(:codincod_api, :password_adapter, Pbkdf2) + end + + defp legacy_adapter_module do + Application.get_env(:codincod_api, :legacy_password_adapter) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/password_reset.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/password_reset.ex new file mode 100644 index 00000000..1e94691d --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/password_reset.ex @@ -0,0 +1,53 @@ +defmodule CodincodApi.Accounts.PasswordReset do + @moduledoc """ + Schema for tracking password reset requests with tokens and expiry. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "password_resets" do + field :token, :string + field :expires_at, :utc_datetime_usec + field :used_at, :utc_datetime_usec + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + token: String.t() | nil, + expires_at: DateTime.t() | nil, + used_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating a new password reset request. + """ + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(reset, attrs) do + reset + |> cast(attrs, [:user_id, :token, :expires_at]) + |> validate_required([:user_id, :token, :expires_at]) + |> unique_constraint(:token) + end + + @doc """ + Marks the reset token as used. + """ + @spec mark_as_used(t()) :: Ecto.Changeset.t() + def mark_as_used(reset) do + reset + |> change(%{used_at: DateTime.utc_now()}) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/preference.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/preference.ex new file mode 100644 index 00000000..523afd04 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/preference.ex @@ -0,0 +1,77 @@ +defmodule CodincodApi.Accounts.Preference do + @moduledoc """ + User preferences including editor configuration and personalization. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + @theme_options ["dark", "light"] + + schema "user_preferences" do + field :legacy_id, :string + field :preferred_language, :string + field :theme, :string + field :blocked_user_ids, {:array, :binary_id}, default: [] + field :editor, :map, default: %{} + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Persistent preferences associated with a user." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + preferred_language: String.t() | nil, + theme: String.t() | nil, + blocked_user_ids: [Ecto.UUID.t()], + editor: map(), + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(preference, attrs) do + preference + |> cast(attrs, [ + :legacy_id, + :preferred_language, + :theme, + :blocked_user_ids, + :editor, + :user_id + ]) + |> validate_required([:user_id]) + |> unique_constraint(:user_id) + |> validate_change(:theme, &validate_theme/2) + |> normalize_editor() + end + + @doc "Available theme options mirrored from the frontend." + @spec theme_options() :: [String.t()] + def theme_options, do: @theme_options + + defp normalize_editor(changeset) do + update_change(changeset, :editor, fn + nil -> %{} + editor when is_map(editor) -> editor + _ -> %{} + end) + end + + defp validate_theme(:theme, nil), do: [] + defp validate_theme(:theme, value) when value in @theme_options, do: [] + + defp validate_theme(:theme, _value) do + [theme: "must be one of #{Enum.join(@theme_options, ", ")} or null"] + end + + defp validate_theme(_field, _value), do: [] +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/user.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/user.ex new file mode 100644 index 00000000..9e41815c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/user.ex @@ -0,0 +1,175 @@ +defmodule CodincodApi.Accounts.User do + @moduledoc """ + User schema mapping the Fastify/Mongo user document to PostgreSQL. + + Mirrors the fields exposed by `UserEntity` from the TypeScript backend: + - `username` + - `email` + - `profile` + - `role` + - moderation counters and ban linkage + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.{User, UserBan, Preference} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @username_min_length 3 + @username_max_length 20 + @username_regex ~r/^[A-Za-z0-9_-]+$/ + @password_min_length 14 + @email_regex ~r/^[^\s@]+@[^\s@]+$/ + + @typedoc """ + Serializable profile payload stored as JSONB. + """ + @type profile :: %{ + optional(String.t()) => String.t() | [String.t()] | nil + } + + schema "users" do + field :legacy_id, :string + field :legacy_username, :string + field :username, :string + field :email, :string + field :password, :string, virtual: true + field :password_confirmation, :string, virtual: true + field :password_hash, :string + field :profile, :map, default: %{} + field :role, :string, default: "user" + field :report_count, :integer, default: 0 + field :ban_count, :integer, default: 0 + field :legacy_current_ban_id, :string + + belongs_to :current_ban, UserBan, foreign_key: :current_ban_id + + has_one :preferences, Preference, foreign_key: :user_id + has_many :user_bans, UserBan, foreign_key: :user_id + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Registered user account record." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + legacy_username: String.t() | nil, + username: String.t() | nil, + email: String.t() | nil, + password: String.t() | nil, + password_confirmation: String.t() | nil, + password_hash: String.t() | nil, + profile: map(), + role: String.t() | nil, + report_count: non_neg_integer() | nil, + ban_count: non_neg_integer() | nil, + legacy_current_ban_id: String.t() | nil, + current_ban_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for user registration. + """ + @spec registration_changeset(User.t(), map()) :: Ecto.Changeset.t() + def registration_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [ + :legacy_id, + :legacy_username, + :username, + :email, + :password, + :password_confirmation, + :profile, + :role + ]) + |> validate_required([:username, :email, :password]) + |> validate_format(:email, @email_regex) + |> validate_length(:username, min: @username_min_length, max: @username_max_length) + |> validate_format(:username, @username_regex) + |> validate_length(:password, min: @password_min_length) + |> validate_confirmation(:password, with: :password_confirmation) + |> put_default_profile() + |> unique_constraint(:username) + |> unique_constraint(:email) + |> put_password_hash() + end + + @doc """ + Changeset for updating profile information. + """ + @spec profile_changeset(User.t(), map()) :: Ecto.Changeset.t() + def profile_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:profile]) + |> put_default_profile() + end + + @doc """ + Changeset for administrative fields such as role. + """ + @spec admin_changeset(User.t(), map()) :: Ecto.Changeset.t() + def admin_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:role, :report_count, :ban_count, :current_ban_id]) + |> validate_inclusion(:role, ["user", "moderator", "admin"]) + end + + @doc false + def reset_password_changeset(%User{} = user, attrs) do + user + |> cast(attrs, [:password, :password_confirmation]) + |> validate_required([:password]) + |> validate_confirmation(:password, with: :password_confirmation) + |> put_password_hash() + end + + @doc "Minimum username length enforced by the backend." + @spec username_min_length() :: pos_integer() + def username_min_length, do: @username_min_length + + @doc "Maximum username length enforced by the backend." + @spec username_max_length() :: pos_integer() + def username_max_length, do: @username_max_length + + @doc "Username format regex used for validation." + @spec username_regex() :: Regex.t() + def username_regex, do: @username_regex + + @doc "Minimum password length enforced by the backend." + @spec password_min_length() :: pos_integer() + def password_min_length, do: @password_min_length + + @doc "Email format regex used for validation." + @spec email_regex() :: Regex.t() + def email_regex, do: @email_regex + + defp put_default_profile(changeset) do + update_change(changeset, :profile, fn + nil -> %{} + profile when is_map(profile) -> profile + _ -> %{} + end) + end + + defp put_password_hash(%Ecto.Changeset{valid?: true} = changeset) do + case fetch_change(changeset, :password) do + {:ok, password} -> + case CodincodApi.Accounts.Password.hash(password) do + {:ok, hash} -> put_change(changeset, :password_hash, hash) + {:error, reason} -> add_error(changeset, :password, reason) + end + + :error -> + changeset + end + end + + defp put_password_hash(changeset), do: changeset +end diff --git a/libs/backend/codincod_api/lib/codincod_api/accounts/user_ban.ex b/libs/backend/codincod_api/lib/codincod_api/accounts/user_ban.ex new file mode 100644 index 00000000..0ac3ea19 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/accounts/user_ban.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Accounts.UserBan do + @moduledoc """ + Represents a moderation ban applied to a user. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "user_bans" do + field :legacy_id, :string + field :ban_type, :string + field :reason, :string + field :metadata, :map, default: %{} + field :expires_at, :utc_datetime_usec + + belongs_to :user, User + belongs_to :banned_by, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Ban metadata tying moderator actions to affected users." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + ban_type: String.t() | nil, + reason: String.t() | nil, + metadata: map(), + expires_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + banned_by_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(ban, attrs) do + ban + |> cast(attrs, [ + :legacy_id, + :ban_type, + :reason, + :metadata, + :expires_at, + :user_id, + :banned_by_id + ]) + |> validate_required([:ban_type, :reason, :user_id, :banned_by_id]) + |> validate_length(:reason, min: 10, max: 500) + |> validate_inclusion(:ban_type, ["temporary", "permanent"]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/application.ex b/libs/backend/codincod_api/lib/codincod_api/application.ex new file mode 100644 index 00000000..e4baad9c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/application.ex @@ -0,0 +1,35 @@ +defmodule CodincodApi.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + CodincodApiWeb.Telemetry, + CodincodApi.Repo, + {DNSCluster, query: Application.get_env(:codincod_api, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: CodincodApi.PubSub}, + {Finch, name: CodincodApiFinch}, + # Start a worker by calling: CodincodApi.Worker.start_link(arg) + # {CodincodApi.Worker, arg}, + # Start to serve requests, typically the last entry + CodincodApiWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: CodincodApi.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + CodincodApiWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/chat.ex b/libs/backend/codincod_api/lib/codincod_api/chat.ex new file mode 100644 index 00000000..73da0c44 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/chat.ex @@ -0,0 +1,81 @@ +defmodule CodincodApi.Chat do + @moduledoc """ + Provides persistence helpers for multiplayer chat transcripts. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Chat.ChatMessage + + @default_preloads [user: [], game: []] + + @spec list_messages_for_game(Ecto.UUID.t(), keyword()) :: [ChatMessage.t()] + def list_messages_for_game(game_id, opts \\ []) do + ChatMessage + |> where([m], m.game_id == ^game_id) + |> maybe_include_deleted(opts) + |> order_by([m], asc: m.inserted_at) + |> maybe_limit(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_message!(Ecto.UUID.t(), keyword()) :: ChatMessage.t() + def get_message!(id, opts \\ []) do + ChatMessage + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec post_message(map(), keyword()) :: {:ok, ChatMessage.t()} | {:error, Ecto.Changeset.t()} + def post_message(attrs, opts \\ []) do + %ChatMessage{} + |> ChatMessage.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec soft_delete_message(ChatMessage.t(), map()) :: + {:ok, ChatMessage.t()} | {:error, Ecto.Changeset.t()} + def soft_delete_message(%ChatMessage{} = message, attrs \\ %{}) do + message + |> ChatMessage.delete_changeset(attrs) + |> Repo.update() + end + + defp maybe_include_deleted(query, opts) do + if Keyword.get(opts, :include_deleted, false) do + query + else + where(query, [m], m.is_deleted == false) + end + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload, @default_preloads) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp maybe_preload_result({:ok, record}, opts) do + preloads = Keyword.get(opts, :preload, @default_preloads) + + {:ok, + case preloads do + nil -> record + _ -> Repo.preload(record, preloads) + end} + end + + defp maybe_preload_result(other, _opts), do: other +end diff --git a/libs/backend/codincod_api/lib/codincod_api/chat/chat_message.ex b/libs/backend/codincod_api/lib/codincod_api/chat/chat_message.ex new file mode 100644 index 00000000..dce8aa49 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/chat/chat_message.ex @@ -0,0 +1,65 @@ +defmodule CodincodApi.Chat.ChatMessage do + @moduledoc """ + Persisted chat messages exchanged inside multiplayer game rooms. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Games.Game + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "chat_messages" do + field :legacy_id, :string + field :username_snapshot, :string + field :message, :string + field :is_deleted, :boolean, default: false + field :deleted_at, :utc_datetime_usec + + belongs_to :game, Game + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "In-game chat message." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + username_snapshot: String.t() | nil, + message: String.t() | nil, + is_deleted: boolean(), + deleted_at: DateTime.t() | nil, + game_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(message, attrs) do + message + |> cast(attrs, [ + :legacy_id, + :username_snapshot, + :message, + :is_deleted, + :deleted_at, + :game_id, + :user_id + ]) + |> validate_required([:username_snapshot, :message, :game_id, :user_id]) + |> validate_length(:message, min: 1, max: 5_000) + end + + @spec delete_changeset(t(), map()) :: Ecto.Changeset.t() + def delete_changeset(message, attrs \\ %{}) do + message + |> cast(attrs, [:is_deleted, :deleted_at]) + |> change(is_deleted: true) + |> put_change(:deleted_at, Map.get(attrs, :deleted_at, DateTime.utc_now())) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/comments.ex b/libs/backend/codincod_api/lib/codincod_api/comments.ex new file mode 100644 index 00000000..73d20900 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/comments.ex @@ -0,0 +1,172 @@ +defmodule CodincodApi.Comments do + @moduledoc """ + Commenting system with nested replies, soft deletion and vote tracking. + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + alias CodincodApi.Repo + + alias CodincodApi.Comments.{Comment, CommentVote} + + @vote_types ["upvote", "downvote"] + @default_preloads [author: [], children: [author: []]] + + @type comment_params :: map() + + @spec list_for_puzzle(Ecto.UUID.t(), keyword()) :: [Comment.t()] + def list_for_puzzle(puzzle_id, opts \\ []) do + Comment + |> where([c], c.puzzle_id == ^puzzle_id) + |> order_by([c], asc: c.inserted_at) + |> exclude_deleted(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec list_replies(Ecto.UUID.t(), keyword()) :: [Comment.t()] + def list_replies(parent_comment_id, opts \\ []) do + Comment + |> where([c], c.parent_comment_id == ^parent_comment_id) + |> order_by([c], asc: c.inserted_at) + |> exclude_deleted(opts) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_comment!(Ecto.UUID.t(), keyword()) :: Comment.t() + def get_comment!(id, opts \\ []) do + Comment + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec get_comment(Ecto.UUID.t(), keyword()) :: Comment.t() | nil + def get_comment(id, opts \\ []) do + Comment + |> maybe_preload(opts) + |> Repo.get(id) + end + + @spec create_comment(comment_params(), keyword()) :: + {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def create_comment(attrs, opts \\ []) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + |> preload_result(opts) + end + + @spec reply(Comment.t(), comment_params(), keyword()) :: + {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def reply(%Comment{id: parent_id, puzzle_id: puzzle_id}, attrs, opts \\ []) do + attrs = + attrs + |> Map.put(:parent_comment_id, parent_id) + |> Map.put_new(:puzzle_id, puzzle_id) + + create_comment(attrs, opts) + end + + @spec soft_delete(Comment.t(), map()) :: {:ok, Comment.t()} | {:error, Ecto.Changeset.t()} + def soft_delete(%Comment{} = comment, attrs \\ %{}) do + comment + |> Comment.delete_changeset(attrs) + |> Repo.update() + end + + @spec toggle_vote(Comment.t(), Ecto.UUID.t(), String.t()) :: + {:ok, Comment.t()} | {:error, term()} + def toggle_vote(%Comment{} = comment, user_id, vote_type) when vote_type in @vote_types do + Multi.new() + |> Multi.run(:existing_vote, fn repo, _changes -> + {:ok, repo.get_by(CommentVote, comment_id: comment.id, user_id: user_id)} + end) + |> Multi.run(:upsert_vote, fn repo, %{existing_vote: existing_vote} -> + handle_vote_transition(repo, existing_vote, comment, user_id, vote_type) + end) + |> Multi.run(:refresh_counts, fn repo, _changes -> + {:ok, recalculate_vote_totals(repo, comment.id)} + end) + |> Multi.run(:comment, fn repo, _changes -> + {:ok, repo.get!(Comment, comment.id)} + end) + |> Repo.transaction() + |> case do + {:ok, %{comment: updated}} -> {:ok, Repo.preload(updated, [:author])} + {:error, _step, reason, _} -> {:error, reason} + end + end + + def toggle_vote(_comment, _user_id, vote_type), do: {:error, {:invalid_vote_type, vote_type}} + + defp handle_vote_transition(repo, nil, comment, user_id, vote_type) do + %CommentVote{} + |> CommentVote.changeset(%{comment_id: comment.id, user_id: user_id, vote_type: vote_type}) + |> repo.insert() + end + + defp handle_vote_transition( + repo, + %CommentVote{vote_type: vote_type} = vote, + _comment, + _user_id, + vote_type + ) do + repo.delete(vote) + end + + defp handle_vote_transition(repo, %CommentVote{} = vote, _comment, _user_id, vote_type) do + vote + |> CommentVote.changeset(%{vote_type: vote_type}) + |> repo.update() + end + + defp recalculate_vote_totals(repo, comment_id) do + counts = + from(v in CommentVote, + where: v.comment_id == ^comment_id, + group_by: v.vote_type, + select: {v.vote_type, count(v.id)} + ) + |> repo.all() + |> Map.new() + + upvotes = Map.get(counts, "upvote", 0) + downvotes = Map.get(counts, "downvote", 0) + + repo.update_all( + from(c in Comment, where: c.id == ^comment_id), + set: [upvote_count: upvotes, downvote_count: downvotes] + ) + + {:ok, %{upvote_count: upvotes, downvote_count: downvotes}} + end + + defp exclude_deleted(query, opts) do + if Keyword.get(opts, :include_deleted, false) do + query + else + where(query, [c], is_nil(c.deleted_at)) + end + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload, @default_preloads) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp preload_result({:ok, comment}, opts) do + preloads = Keyword.get(opts, :preload, @default_preloads) + + {:ok, + case preloads do + nil -> comment + _ -> Repo.preload(comment, preloads) + end} + end + + defp preload_result(other, _opts), do: other +end diff --git a/libs/backend/codincod_api/lib/codincod_api/comments/comment.ex b/libs/backend/codincod_api/lib/codincod_api/comments/comment.ex new file mode 100644 index 00000000..8553b349 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/comments/comment.ex @@ -0,0 +1,110 @@ +defmodule CodincodApi.Comments.Comment do + @moduledoc """ + Persistent representation of user-authored comments across puzzles and submissions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Comments.{Comment, CommentVote} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @comment_types ["puzzle-comment", "comment-comment"] + + schema "comments" do + field :legacy_id, :string + field :body, :string + field :comment_type, :string, default: "comment-comment" + field :upvote_count, :integer, default: 0 + field :downvote_count, :integer, default: 0 + field :metadata, :map, default: %{} + field :deleted_at, :utc_datetime_usec + + belongs_to :author, User + belongs_to :puzzle, Puzzle + belongs_to :submission, Submission + belongs_to :parent_comment, Comment + + has_many :children, Comment, foreign_key: :parent_comment_id + has_many :votes, CommentVote + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Domain comment entity." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + body: String.t() | nil, + comment_type: String.t(), + upvote_count: non_neg_integer(), + downvote_count: non_neg_integer(), + metadata: map(), + deleted_at: DateTime.t() | nil, + author_id: Ecto.UUID.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + submission_id: Ecto.UUID.t() | nil, + parent_comment_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset used when creating or updating a comment. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(comment, attrs) do + comment + |> cast(attrs, [ + :legacy_id, + :body, + :comment_type, + :upvote_count, + :downvote_count, + :metadata, + :deleted_at, + :author_id, + :puzzle_id, + :submission_id, + :parent_comment_id + ]) + |> validate_required([:body, :author_id]) + |> validate_length(:body, min: 1, max: 5_000) + |> put_comment_type_default() + |> validate_inclusion(:comment_type, @comment_types) + |> normalize_metadata() + end + + @doc """ + Changeset to mark a comment as deleted (soft delete). + """ + @spec delete_changeset(t(), map()) :: Ecto.Changeset.t() + def delete_changeset(comment, attrs) do + comment + |> cast(attrs, [:deleted_at, :metadata]) + |> put_change(:deleted_at, Map.get(attrs, :deleted_at, DateTime.utc_now())) + |> normalize_metadata() + end + + defp put_comment_type_default(%Ecto.Changeset{} = changeset) do + case {get_field(changeset, :comment_type), get_field(changeset, :parent_comment_id), + get_field(changeset, :puzzle_id)} do + {nil, nil, _puzzle_id} -> put_change(changeset, :comment_type, "puzzle-comment") + {nil, _parent_id, _} -> put_change(changeset, :comment_type, "comment-comment") + _ -> changeset + end + end + + defp normalize_metadata(%Ecto.Changeset{} = changeset) do + update_change(changeset, :metadata, fn + nil -> %{} + metadata when is_map(metadata) -> metadata + _ -> %{} + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/comments/comment_vote.ex b/libs/backend/codincod_api/lib/codincod_api/comments/comment_vote.ex new file mode 100644 index 00000000..758e7962 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/comments/comment_vote.ex @@ -0,0 +1,44 @@ +defmodule CodincodApi.Comments.CommentVote do + @moduledoc """ + Represents a single user's vote (upvote/downvote) on a comment. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Comments.Comment + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @vote_types ["upvote", "downvote"] + + schema "comment_votes" do + field :vote_type, :string + + belongs_to :comment, Comment + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "User's vote on a comment." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + vote_type: String.t(), + comment_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(vote, attrs) do + vote + |> cast(attrs, [:vote_type, :comment_id, :user_id]) + |> validate_required([:vote_type, :comment_id, :user_id]) + |> validate_inclusion(:vote_type, @vote_types) + |> unique_constraint([:comment_id, :user_id]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/games.ex b/libs/backend/codincod_api/lib/codincod_api/games.ex new file mode 100644 index 00000000..fa95ca6f --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/games.ex @@ -0,0 +1,115 @@ +defmodule CodincodApi.Games do + @moduledoc """ + Games context encapsulating multiplayer lobby management and player membership. + """ + + import Ecto.Query, warn: false + alias Ecto.{Changeset, Multi} + alias CodincodApi.Repo + + alias CodincodApi.Games.{Game, GamePlayer} + + @type game_params :: map() + + @spec list_waiting_rooms() :: [Game.t()] + def list_waiting_rooms do + Game + |> where([g], g.status == "waiting") + |> preload([:owner, :puzzle, players: :user]) + |> Repo.all() + end + + @spec get_game!(Ecto.UUID.t(), keyword()) :: Game.t() + def get_game!(id, opts \\ []) do + Game + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec create_game(game_params()) :: {:ok, Game.t()} | {:error, Ecto.Changeset.t()} + def create_game(attrs) do + with {:ok, owner_id} <- fetch_owner_id(attrs) do + Multi.new() + |> Multi.insert(:game, Game.changeset(%Game{}, attrs)) + |> Multi.run(:host, fn repo, %{game: game} -> + %GamePlayer{} + |> GamePlayer.changeset(%{ + user_id: owner_id, + game_id: game.id, + joined_at: DateTime.utc_now(), + role: "host" + }) + |> repo.insert() + end) + |> Repo.transaction() + |> case do + {:ok, %{game: game}} -> {:ok, preload_assocs(game)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + end + + @spec join_game(Game.t(), map()) :: {:ok, GamePlayer.t()} | {:error, Ecto.Changeset.t()} + def join_game(%Game{id: game_id}, %{user_id: _user_id} = attrs) do + %GamePlayer{} + |> GamePlayer.changeset( + attrs + |> Map.put(:game_id, game_id) + |> Map.put_new(:joined_at, DateTime.utc_now()) + |> Map.put_new(:role, "player") + ) + |> Repo.insert() + end + + @spec leave_game(Game.t(), Ecto.UUID.t()) :: :ok + def leave_game(%Game{id: game_id}, user_id) do + Repo.delete_all( + from gp in GamePlayer, where: gp.game_id == ^game_id and gp.user_id == ^user_id + ) + + :ok + end + + @spec transition_game(Game.t(), String.t(), map()) :: + {:ok, Game.t()} | {:error, Ecto.Changeset.t()} + def transition_game(%Game{} = game, status, attrs \\ %{}) do + game + |> Game.changeset(Map.merge(attrs, %{status: status})) + |> Repo.update() + end + + @spec list_games_for_user(Ecto.UUID.t()) :: [Game.t()] + def list_games_for_user(user_id) do + Game + |> join(:inner, [g], gp in assoc(g, :players)) + |> where([_g, gp], gp.user_id == ^user_id) + |> preload([:owner, :puzzle, players: :user]) + |> Repo.all() + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp preload_assocs(game) do + Repo.preload(game, [:owner, :puzzle, players: :user]) + end + + defp fetch_owner_id(attrs) do + case Map.get(attrs, :owner_id) || Map.get(attrs, "owner_id") do + nil -> + changeset = + %Game{} + |> Game.changeset(attrs) + |> Changeset.add_error(:owner_id, "can't be blank") + + {:error, changeset} + + owner_id -> + {:ok, owner_id} + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/games/game.ex b/libs/backend/codincod_api/lib/codincod_api/games/game.ex new file mode 100644 index 00000000..8177992e --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/games/game.ex @@ -0,0 +1,96 @@ +defmodule CodincodApi.Games.Game do + @moduledoc """ + Game schema representing multiplayer sessions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Games.GamePlayer + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "games" do + field :legacy_id, :string + field :visibility, :string + field :mode, :string + field :rated, :boolean, default: true + field :status, :string, default: "waiting" + field :max_duration_seconds, :integer, default: 600 + field :allowed_language_ids, {:array, :binary_id}, default: [] + field :options, :map, default: %{} + field :started_at, :utc_datetime_usec + field :ended_at, :utc_datetime_usec + + belongs_to :owner, User + belongs_to :puzzle, Puzzle + + has_many :players, GamePlayer + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Multiplayer game session metadata." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + visibility: String.t() | nil, + mode: String.t() | nil, + rated: boolean() | nil, + status: String.t() | nil, + max_duration_seconds: integer() | nil, + allowed_language_ids: [Ecto.UUID.t()], + options: map(), + started_at: DateTime.t() | nil, + ended_at: DateTime.t() | nil, + owner_id: Ecto.UUID.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(game, attrs) do + game + |> cast(attrs, [ + :legacy_id, + :owner_id, + :puzzle_id, + :visibility, + :mode, + :rated, + :status, + :max_duration_seconds, + :allowed_language_ids, + :options, + :started_at, + :ended_at + ]) + |> validate_required([:owner_id, :puzzle_id, :visibility, :mode]) + |> validate_inclusion(:visibility, ["public", "private", "friends"]) + |> validate_inclusion(:status, ["waiting", "in_progress", "completed", "cancelled"]) + |> validate_inclusion(:mode, [ + "FASTEST", + "SHORTEST", + "BACKWARDS", + "HARDCORE", + "DEBUG", + "TYPERACER", + "EFFICIENCY", + "INCREMENTAL", + "RANDOM" + ]) + |> put_default_options() + end + + defp put_default_options(changeset) do + update_change(changeset, :options, fn + nil -> %{} + options when is_map(options) -> options + _ -> %{} + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/games/game_player.ex b/libs/backend/codincod_api/lib/codincod_api/games/game_player.ex new file mode 100644 index 00000000..c01bf2c4 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/games/game_player.ex @@ -0,0 +1,61 @@ +defmodule CodincodApi.Games.GamePlayer do + @moduledoc """ + Join table linking users to games with metadata about their participation. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Games.Game + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "game_players" do + field :legacy_id, :string + field :joined_at, :utc_datetime_usec + field :left_at, :utc_datetime_usec + field :role, :string, default: "player" + field :score, :integer + field :placement, :integer + + belongs_to :game, Game + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Join association for users participating in a multiplayer game." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + joined_at: DateTime.t() | nil, + left_at: DateTime.t() | nil, + role: String.t() | nil, + score: integer() | nil, + placement: integer() | nil, + game_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(player, attrs) do + player + |> cast(attrs, [ + :legacy_id, + :joined_at, + :left_at, + :role, + :score, + :placement, + :game_id, + :user_id + ]) + |> validate_required([:joined_at, :game_id, :user_id]) + |> validate_inclusion(:role, ["player", "spectator", "host"]) + |> unique_constraint([:game_id, :user_id]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/languages.ex b/libs/backend/codincod_api/lib/codincod_api/languages.ex new file mode 100644 index 00000000..2699c905 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/languages.ex @@ -0,0 +1,47 @@ +defmodule CodincodApi.Languages do + @moduledoc """ + Context for managing programming languages leveraged by submissions and puzzles. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Languages.ProgrammingLanguage + + @doc """ + Lists all active programming languages sorted by display order and name. + """ + def list_languages(opts \\ []) do + include_inactive = Keyword.get(opts, :include_inactive, false) + + ProgrammingLanguage + |> where([pl], pl.is_active == true or ^include_inactive) + |> order_by([pl], asc_nulls_last: pl.display_order, asc: pl.language, asc: pl.version) + |> Repo.all() + end + + @doc """ + Retrieves a language by identifier. + """ + def get_language!(id), do: Repo.get!(ProgrammingLanguage, id) + + @spec get_language(Ecto.UUID.t()) :: ProgrammingLanguage.t() | nil + def get_language(id), do: Repo.get(ProgrammingLanguage, id) + + @spec fetch_language(Ecto.UUID.t()) :: {:ok, ProgrammingLanguage.t()} | {:error, :not_found} + def fetch_language(id) do + case get_language(id) do + nil -> {:error, :not_found} + language -> {:ok, language} + end + end + + def upsert_language(attrs) do + %ProgrammingLanguage{} + |> ProgrammingLanguage.changeset(attrs) + |> Repo.insert( + conflict_target: [:language, :version], + on_conflict: {:replace_all_except, [:id, :inserted_at]} + ) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/languages/programming_language.ex b/libs/backend/codincod_api/lib/codincod_api/languages/programming_language.ex new file mode 100644 index 00000000..3af4d011 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/languages/programming_language.ex @@ -0,0 +1,55 @@ +defmodule CodincodApi.Languages.ProgrammingLanguage do + @moduledoc """ + Programming language entity mirrored from the Node backend. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "programming_languages" do + field :legacy_id, :string + field :language, :string + field :version, :string + field :aliases, {:array, :string}, default: [] + field :runtime, :string + field :display_order, :integer + field :is_active, :boolean, default: true + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Data representation of a programming language runtime entry." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + language: String.t() | nil, + version: String.t() | nil, + aliases: [String.t()], + runtime: String.t() | nil, + display_order: integer() | nil, + is_active: boolean() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(language, attrs) do + language + |> cast(attrs, [ + :legacy_id, + :language, + :version, + :aliases, + :runtime, + :display_order, + :is_active + ]) + |> validate_required([:language, :version]) + |> unique_constraint([:language, :version], + name: :programming_languages_language_version_index + ) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/mailer.ex b/libs/backend/codincod_api/lib/codincod_api/mailer.ex new file mode 100644 index 00000000..cc90f56f --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/mailer.ex @@ -0,0 +1,3 @@ +defmodule CodincodApi.Mailer do + use Swoosh.Mailer, otp_app: :codincod_api +end diff --git a/libs/backend/codincod_api/lib/codincod_api/metrics.ex b/libs/backend/codincod_api/lib/codincod_api/metrics.ex new file mode 100644 index 00000000..e1892ede --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/metrics.ex @@ -0,0 +1,86 @@ +defmodule CodincodApi.Metrics do + @moduledoc """ + Centralises leaderboard statistics, ratings and cached leaderboard snapshots. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Metrics.{UserMetric, LeaderboardSnapshot} + + ## User metrics -------------------------------------------------------------- + + @spec get_user_metric(Ecto.UUID.t()) :: UserMetric.t() | nil + def get_user_metric(user_id) do + Repo.get_by(UserMetric, user_id: user_id) + end + + @spec get_user_metric!(Ecto.UUID.t()) :: UserMetric.t() + def get_user_metric!(user_id) do + Repo.get_by!(UserMetric, user_id: user_id) + end + + @spec upsert_user_metric(map()) :: {:ok, UserMetric.t()} | {:error, Ecto.Changeset.t()} + def upsert_user_metric(attrs) do + %UserMetric{} + |> UserMetric.changeset(attrs) + |> Repo.insert( + conflict_target: [:user_id], + on_conflict: {:replace_all_except, [:id, :inserted_at, :user_id]} + ) + end + + @spec update_user_metric(UserMetric.t(), map()) :: + {:ok, UserMetric.t()} | {:error, Ecto.Changeset.t()} + def update_user_metric(%UserMetric{} = metric, attrs) do + metric + |> UserMetric.changeset(attrs) + |> Repo.update() + end + + ## Leaderboard snapshots ----------------------------------------------------- + + @spec list_snapshots(keyword()) :: [LeaderboardSnapshot.t()] + def list_snapshots(opts \\ []) do + LeaderboardSnapshot + |> maybe_filter_snapshots(opts) + |> order_by([s], desc: s.captured_at) + |> maybe_limit(opts) + |> Repo.all() + end + + @spec latest_snapshot(String.t()) :: LeaderboardSnapshot.t() | nil + def latest_snapshot(game_mode) do + LeaderboardSnapshot + |> where([s], s.game_mode == ^game_mode) + |> order_by([s], desc: s.captured_at) + |> limit(1) + |> Repo.one() + end + + @spec record_snapshot(map()) :: {:ok, LeaderboardSnapshot.t()} | {:error, Ecto.Changeset.t()} + def record_snapshot(attrs) do + %LeaderboardSnapshot{} + |> LeaderboardSnapshot.changeset(attrs) + |> Repo.insert() + end + + ## Helpers ------------------------------------------------------------------ + + defp maybe_filter_snapshots(query, opts) do + Enum.reduce(opts, query, fn + {:game_mode, mode}, acc when is_binary(mode) -> where(acc, [s], s.game_mode == ^mode) + {:captured_after, %DateTime{} = dt}, acc -> where(acc, [s], s.captured_at >= ^dt) + {:captured_before, %DateTime{} = dt}, acc -> where(acc, [s], s.captured_at <= ^dt) + {_key, _value}, acc -> acc + end) + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex b/libs/backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex new file mode 100644 index 00000000..a105f45e --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/metrics/leaderboard_snapshot.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Metrics.LeaderboardSnapshot do + @moduledoc """ + Immutable snapshot of leaderboard standings for a specific game mode. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "leaderboard_snapshots" do + field :game_mode, :string + field :captured_at, :utc_datetime_usec + field :entries, {:array, :map}, default: [] + field :metadata, :map, default: %{} + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Leaderboard capture for auditing or caching leaderboard responses." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + game_mode: String.t() | nil, + captured_at: DateTime.t() | nil, + entries: [map()], + metadata: map(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(snapshot, attrs) do + snapshot + |> cast(attrs, [:game_mode, :captured_at, :entries, :metadata]) + |> validate_required([:game_mode]) + |> put_change(:captured_at, Map.get(attrs, :captured_at, DateTime.utc_now())) + |> normalize_entries() + |> normalize_metadata() + end + + defp normalize_entries(changeset) do + update_change(changeset, :entries, fn + nil -> [] + value when is_list(value) -> value + _ -> [] + end) + end + + defp normalize_metadata(changeset) do + update_change(changeset, :metadata, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/metrics/user_metric.ex b/libs/backend/codincod_api/lib/codincod_api/metrics/user_metric.ex new file mode 100644 index 00000000..2348a984 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/metrics/user_metric.ex @@ -0,0 +1,72 @@ +defmodule CodincodApi.Metrics.UserMetric do + @moduledoc """ + Aggregated rating information for a user across all multiplayer modes. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "user_metrics" do + field :legacy_id, :string + field :global_rating, :float, default: 1_500.0 + field :global_rating_deviation, :float, default: 350.0 + field :global_rating_volatility, :float, default: 0.06 + field :modes, :map, default: %{} + field :totals, :map, default: %{} + field :last_processed_game_at, :utc_datetime_usec + field :last_calculated_at, :utc_datetime_usec + + belongs_to :user, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Statistics for a user's performance across game modes." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + global_rating: float(), + global_rating_deviation: float(), + global_rating_volatility: float(), + modes: map(), + totals: map(), + last_processed_game_at: DateTime.t() | nil, + last_calculated_at: DateTime.t() | nil, + user_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(metric, attrs) do + metric + |> cast(attrs, [ + :legacy_id, + :global_rating, + :global_rating_deviation, + :global_rating_volatility, + :modes, + :totals, + :last_processed_game_at, + :last_calculated_at, + :user_id + ]) + |> validate_required([:user_id]) + |> normalize_maps([:modes, :totals]) + end + + defp normalize_maps(changeset, fields) do + Enum.reduce(fields, changeset, fn field, acc -> + update_change(acc, field, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/moderation.ex b/libs/backend/codincod_api/lib/codincod_api/moderation.ex new file mode 100644 index 00000000..8b2f41fd --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/moderation.ex @@ -0,0 +1,129 @@ +defmodule CodincodApi.Moderation do + @moduledoc """ + Moderation workflows for handling reports, reviews, and automated escalation hooks. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Moderation.{Report, ModerationReview} + + @type report_filters :: %{ + optional(:status) => String.t(), + optional(:problem_type) => String.t(), + optional(:reported_by_id) => Ecto.UUID.t(), + optional(:resolved_by_id) => Ecto.UUID.t() + } + @type review_filters :: %{ + optional(:status) => String.t(), + optional(:puzzle_id) => Ecto.UUID.t() + } + + ## Reports ------------------------------------------------------------------ + + @spec list_reports(report_filters(), keyword()) :: [Report.t()] + def list_reports(filters \\ %{}, opts \\ []) do + Report + |> apply_report_filters(filters) + |> order_by([r], desc: r.inserted_at) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_report!(Ecto.UUID.t(), keyword()) :: Report.t() + def get_report!(id, opts \\ []) do + Report + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec create_report(map(), keyword()) :: {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def create_report(attrs, opts \\ []) do + %Report{} + |> Report.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec resolve_report(Report.t(), map(), keyword()) :: + {:ok, Report.t()} | {:error, Ecto.Changeset.t()} + def resolve_report(%Report{} = report, attrs, opts \\ []) do + report + |> Report.resolve_changeset(attrs) + |> Repo.update() + |> maybe_preload_result(opts) + end + + ## Reviews ------------------------------------------------------------------ + + @spec list_reviews(review_filters(), keyword()) :: [ModerationReview.t()] + def list_reviews(filters \\ %{}, opts \\ []) do + ModerationReview + |> apply_review_filters(filters) + |> order_by([r], asc: r.inserted_at) + |> maybe_preload(opts) + |> Repo.all() + end + + @spec get_review!(Ecto.UUID.t(), keyword()) :: ModerationReview.t() + def get_review!(id, opts \\ []) do + ModerationReview + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec queue_review(map(), keyword()) :: + {:ok, ModerationReview.t()} | {:error, Ecto.Changeset.t()} + def queue_review(attrs, opts \\ []) do + %ModerationReview{} + |> ModerationReview.create_changeset(attrs) + |> Repo.insert() + |> maybe_preload_result(opts) + end + + @spec update_review(ModerationReview.t(), map(), keyword()) :: + {:ok, ModerationReview.t()} | {:error, Ecto.Changeset.t()} + def update_review(%ModerationReview{} = review, attrs, opts \\ []) do + review + |> ModerationReview.update_changeset(attrs) + |> Repo.update() + |> maybe_preload_result(opts) + end + + ## Helpers ------------------------------------------------------------------ + + defp apply_report_filters(query, filters) do + Enum.reduce(filters, query, fn + {:status, status}, acc -> where(acc, [r], r.status == ^status) + {:problem_type, type}, acc -> where(acc, [r], r.problem_type == ^type) + {:reported_by_id, user_id}, acc -> where(acc, [r], r.reported_by_id == ^user_id) + {:resolved_by_id, user_id}, acc -> where(acc, [r], r.resolved_by_id == ^user_id) + {_key, _value}, acc -> acc + end) + end + + defp apply_review_filters(query, filters) do + Enum.reduce(filters, query, fn + {:status, status}, acc -> where(acc, [r], r.status == ^status) + {:puzzle_id, puzzle_id}, acc -> where(acc, [r], r.puzzle_id == ^puzzle_id) + {:reviewer_id, reviewer_id}, acc -> where(acc, [r], r.reviewer_id == ^reviewer_id) + {_key, _value}, acc -> acc + end) + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preloads -> preload(query, ^preloads) + end + end + + defp maybe_preload_result({:ok, record}, opts) do + case Keyword.get(opts, :preload) do + nil -> {:ok, record} + preloads -> {:ok, Repo.preload(record, preloads)} + end + end + + defp maybe_preload_result(other, _opts), do: other +end diff --git a/libs/backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex b/libs/backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex new file mode 100644 index 00000000..3565ee6a --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/moderation/moderation_review.ex @@ -0,0 +1,81 @@ +defmodule CodincodApi.Moderation.ModerationReview do + @moduledoc """ + Represents a moderation workflow entry for a puzzle awaiting approval. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @statuses ["pending", "approved", "rejected", "revision_requested"] + + schema "moderation_reviews" do + field :legacy_id, :string + field :status, :string, default: "pending" + field :notes, :string + field :submitted_at, :utc_datetime_usec + field :resolved_at, :utc_datetime_usec + + belongs_to :puzzle, Puzzle + belongs_to :reviewer, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Puzzle moderation review lifecycle entity." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + status: String.t(), + notes: String.t() | nil, + submitted_at: DateTime.t() | nil, + resolved_at: DateTime.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + reviewer_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(review, attrs) do + review + |> cast(attrs, [ + :legacy_id, + :status, + :notes, + :submitted_at, + :resolved_at, + :puzzle_id, + :reviewer_id + ]) + |> validate_required([:puzzle_id]) + |> validate_inclusion(:status, @statuses) + |> put_change(:submitted_at, Map.get(attrs, :submitted_at, DateTime.utc_now())) + end + + @spec update_changeset(t(), map()) :: Ecto.Changeset.t() + def update_changeset(review, attrs) do + review + |> cast(attrs, [:status, :notes, :resolved_at, :reviewer_id]) + |> validate_inclusion(:status, @statuses) + |> maybe_put_resolved_at(attrs) + end + + defp maybe_put_resolved_at(changeset, attrs) do + case {get_field(changeset, :status), Map.get(attrs, :resolved_at)} do + {status, nil} when status in ["approved", "rejected", "revision_requested"] -> + put_change(changeset, :resolved_at, DateTime.utc_now()) + + {_, %DateTime{} = resolved_at} -> + put_change(changeset, :resolved_at, resolved_at) + + _ -> + changeset + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/moderation/report.ex b/libs/backend/codincod_api/lib/codincod_api/moderation/report.ex new file mode 100644 index 00000000..e8ef8a60 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/moderation/report.ex @@ -0,0 +1,91 @@ +defmodule CodincodApi.Moderation.Report do + @moduledoc """ + User submitted report describing problematic content or behaviour. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @problem_types ["puzzle", "user", "comment", "game_chat"] + @statuses ["pending", "resolved", "rejected"] + + schema "reports" do + field :legacy_id, :string + field :problem_type, :string + field :problem_reference_id, :binary_id + field :problem_reference_snapshot, :map, default: %{} + field :explanation, :string + field :status, :string, default: "pending" + field :resolution_notes, :string + field :resolved_at, :utc_datetime_usec + field :metadata, :map, default: %{} + + belongs_to :reported_by, User + belongs_to :resolved_by, User + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Report awaiting moderation handling." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + problem_type: String.t(), + problem_reference_id: Ecto.UUID.t() | nil, + problem_reference_snapshot: map(), + explanation: String.t() | nil, + status: String.t(), + resolution_notes: String.t() | nil, + resolved_at: DateTime.t() | nil, + metadata: map(), + reported_by_id: Ecto.UUID.t() | nil, + resolved_by_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(report, attrs) do + report + |> cast(attrs, [ + :legacy_id, + :problem_type, + :problem_reference_id, + :problem_reference_snapshot, + :explanation, + :status, + :metadata, + :reported_by_id + ]) + |> validate_required([:problem_type, :problem_reference_id, :explanation, :reported_by_id]) + |> validate_length(:explanation, min: 10, max: 2_000) + |> validate_inclusion(:problem_type, @problem_types) + |> validate_inclusion(:status, @statuses) + |> normalize_map_fields([:problem_reference_snapshot, :metadata]) + end + + @spec resolve_changeset(t(), map()) :: Ecto.Changeset.t() + def resolve_changeset(report, attrs) do + report + |> cast(attrs, [:status, :resolution_notes, :resolved_by_id, :resolved_at, :metadata]) + |> validate_required([:status, :resolved_by_id]) + |> validate_inclusion(:status, @statuses) + |> normalize_map_fields([:metadata]) + |> put_change(:resolved_at, Map.get(attrs, :resolved_at, DateTime.utc_now())) + end + + defp normalize_map_fields(changeset, fields) do + Enum.reduce(fields, changeset, fn field, acc -> + update_change(acc, field, fn + nil -> %{} + value when is_map(value) -> value + _ -> %{} + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/piston.ex b/libs/backend/codincod_api/lib/codincod_api/piston.ex new file mode 100644 index 00000000..8254fb4b --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/piston.ex @@ -0,0 +1,38 @@ +defmodule CodincodApi.Piston do + @moduledoc """ + Facade module for interacting with the Piston execution service. The concrete + client module can be swapped in configuration via the + `:codincod_api, :piston_client` setting which defaults to + `CodincodApi.Piston.Client`. + """ + + @typedoc "Represents a single language runtime entry exposed by Piston." + @type runtime :: %{ + required(:language) => String.t(), + required(:version) => String.t(), + optional(:aliases) => list(String.t()), + optional(:runtime) => String.t() + } + + @typedoc "Response map returned by Piston's execute endpoint." + @type execution_response :: map() + + @callback list_runtimes() :: {:ok, [runtime()]} | {:error, term()} + @callback execute(map()) :: {:ok, execution_response()} | {:error, term()} + + @doc "Returns the list of Piston runtimes available for code execution." + @spec list_runtimes() :: {:ok, [runtime()]} | {:error, term()} + def list_runtimes do + client().list_runtimes() + end + + @doc "Executes code by delegating to the configured client module." + @spec execute(map()) :: {:ok, execution_response()} | {:error, term()} + def execute(request) when is_map(request) do + client().execute(request) + end + + defp client do + Application.get_env(:codincod_api, :piston_client, CodincodApi.Piston.Client) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/piston/client.ex b/libs/backend/codincod_api/lib/codincod_api/piston/client.ex new file mode 100644 index 00000000..81d7bfe7 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/piston/client.ex @@ -0,0 +1,57 @@ +defmodule CodincodApi.Piston.Client do + @moduledoc """ + Tesla-powered implementation that communicates with a Piston server. + """ + + @behaviour CodincodApi.Piston + + alias Tesla.Env + + @execute_path "/api/v2/execute" + @runtimes_path "/api/v2/runtimes" + + @impl CodincodApi.Piston + def list_runtimes do + case Tesla.get(client(), @runtimes_path) do + {:ok, %Env{status: status, body: body}} when status in 200..299 and is_list(body) -> + {:ok, body} + + {:ok, %Env{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @impl CodincodApi.Piston + def execute(request) when is_map(request) do + case Tesla.post(client(), @execute_path, request) do + {:ok, %Env{status: status, body: body}} when status in 200..299 and is_map(body) -> + {:ok, body} + + {:ok, %Env{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + defp client do + middleware = [ + {Tesla.Middleware.BaseUrl, base_url()}, + Tesla.Middleware.JSON, + {Tesla.Middleware.Timeout, timeout: 15_000} + ] + + adapter = {Tesla.Adapter.Finch, name: CodincodApiFinch} + + Tesla.client(middleware, adapter) + end + + defp base_url do + config = Application.get_env(:codincod_api, :piston, []) + Keyword.get(config, :base_url, "http://localhost:2000") + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/piston/mock.ex b/libs/backend/codincod_api/lib/codincod_api/piston/mock.ex new file mode 100644 index 00000000..dccc65de --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/piston/mock.ex @@ -0,0 +1,47 @@ +defmodule CodincodApi.Piston.Mock do + @moduledoc """ + In-memory mock client used in tests to avoid hitting a real Piston instance. + By default it echoes the provided stdin as stdout so validators expecting + matching output succeed. Tests can override the behaviour by setting the + `:piston_mock_execute` application environment to a `fun/1`. + """ + + @behaviour CodincodApi.Piston + + @impl CodincodApi.Piston + def list_runtimes do + {:ok, + [ + %{ + "language" => "python", + "version" => "3.10.0", + "aliases" => ["py"], + "runtime" => "cpython" + } + ]} + end + + @impl CodincodApi.Piston + def execute(request) when is_map(request) do + case Application.get_env(:codincod_api, :piston_mock_execute) do + fun when is_function(fun, 1) -> fun.(request) + _ -> {:ok, default_success(request)} + end + end + + defp default_success(request) do + stdin = Map.get(request, "stdin") || Map.get(request, :stdin) || "" + + %{ + "language" => Map.get(request, "language") || Map.get(request, :language) || "python", + "version" => Map.get(request, "version") || Map.get(request, :version) || "3.10.0", + "run" => %{ + "output" => to_string(stdin), + "stdout" => to_string(stdin), + "stderr" => "", + "signal" => nil, + "code" => 0 + } + } + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles.ex new file mode 100644 index 00000000..577d768e --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles.ex @@ -0,0 +1,334 @@ +defmodule CodincodApi.Puzzles do + @moduledoc """ + Puzzle context that encapsulates authoring flows, moderation transitions and + validator management. + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + alias CodincodApi.Repo + + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator, PuzzleMetric} + + @default_page 1 + @default_page_size 20 + @min_page 1 + @min_page_size 1 + @max_page_size 100 + + @type puzzle_params :: map() + @type pagination_opts :: %{optional(:page) => integer(), optional(:page_size) => integer()} + + @doc """ + Paginate puzzles mirroring the Fastify `/puzzle` index route behaviour. + + Ensures bounds on `page` and `page_size`, preloads associations required by the + API and returns the aggregated counts needed for the paginated response. + """ + @spec paginate_all(pagination_opts() | keyword()) :: %{ + items: [Puzzle.t()], + page: pos_integer(), + page_size: pos_integer(), + total_items: non_neg_integer(), + total_pages: non_neg_integer() + } + def paginate_all(params \\ %{}) do + %{page: page, page_size: page_size} = normalize_pagination(params) + + offset = (page - 1) * page_size + + items = + base_query() + |> order_by([p], desc: p.inserted_at) + |> limit(^page_size) + |> offset(^offset) + |> Repo.all() + + total_items = Repo.aggregate(from(p in Puzzle), :count, :id) + + total_pages = + if total_items == 0 do + 0 + else + total_items + |> Kernel./(page_size) + |> Float.ceil() + |> trunc() + end + + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } + end + + @doc """ + Paginate puzzles authored by a specific user while applying visibility rules. + + Mirrors the behaviour of the Fastify `/user/:username/puzzle` route where the + owner can see all of their puzzles, but other viewers are limited to + `approved` visibility. + """ + @spec paginate_for_author(Ecto.UUID.t(), map() | keyword(), keyword()) :: %{ + items: [Puzzle.t()], + page: pos_integer(), + page_size: pos_integer(), + total_items: non_neg_integer(), + total_pages: non_neg_integer() + } + def paginate_for_author(author_id, params \\ %{}, opts \\ []) do + %{page: page, page_size: page_size} = normalize_pagination(params) + + viewer_id = Keyword.get(opts, :viewer_id) + include_private = viewer_id == author_id || Keyword.get(opts, :include_private, false) + + filtered_query = + base_query() + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(include_private) + + offset = (page - 1) * page_size + + items = + filtered_query + |> order_by([p], desc: p.inserted_at) + |> limit(^page_size) + |> offset(^offset) + |> Repo.all() + + total_items = + Puzzle + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(include_private) + |> Repo.aggregate(:count, :id) + + total_pages = + if total_items == 0 do + 0 + else + total_items + |> Kernel./(page_size) + |> Float.ceil() + |> trunc() + end + + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } + end + + @spec list_published(keyword()) :: [Puzzle.t()] + def list_published(opts \\ []) do + base_query() + |> maybe_filter_visibility(false) + |> maybe_filter_by_author(opts) + |> maybe_filter_by_tags(opts) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @doc """ + Lists public (approved) puzzles authored by the given user. + """ + @spec list_author_public(Ecto.UUID.t()) :: [Puzzle.t()] + def list_author_public(author_id) do + base_query() + |> where([p], p.author_id == ^author_id) + |> maybe_filter_visibility(false) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @doc """ + Lists every puzzle authored by the given user regardless of visibility. + Intended for authenticated owners viewing their own content. + """ + @spec list_author_all(Ecto.UUID.t()) :: [Puzzle.t()] + def list_author_all(author_id) do + base_query() + |> where([p], p.author_id == ^author_id) + |> order_by([p], desc: p.inserted_at) + |> Repo.all() + end + + @spec get_puzzle!(Ecto.UUID.t(), keyword()) :: Puzzle.t() + def get_puzzle!(id, opts \\ []) do + base_query() + |> maybe_preload(opts) + |> Repo.get!(id) + end + + @spec get_puzzle(Ecto.UUID.t()) :: Puzzle.t() | nil + def get_puzzle(id) do + base_query() + |> Repo.get(id) + end + + @spec fetch_puzzle_with_validators(Ecto.UUID.t()) :: {:ok, Puzzle.t()} | {:error, :not_found} + def fetch_puzzle_with_validators(id) do + case get_puzzle(id) do + nil -> {:error, :not_found} + puzzle -> {:ok, puzzle} + end + end + + @doc """ + Fetches a puzzle by ID, returning {:ok, puzzle} or {:error, :not_found}. + """ + @spec fetch_puzzle(Ecto.UUID.t()) :: {:ok, Puzzle.t()} | {:error, :not_found} + def fetch_puzzle(id) do + case get_puzzle(id) do + nil -> {:error, :not_found} + puzzle -> {:ok, puzzle} + end + end + + @spec create_puzzle(puzzle_params()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def create_puzzle(attrs) do + Multi.new() + |> Multi.insert(:puzzle, Puzzle.changeset(%Puzzle{}, attrs)) + |> Multi.run(:validators, fn repo, %{puzzle: puzzle} -> + upsert_validators(repo, puzzle, Map.get(attrs, :validators, [])) + end) + |> Repo.transaction() + |> case do + {:ok, %{puzzle: puzzle}} -> {:ok, preload_assocs(puzzle)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + + @spec update_puzzle(Puzzle.t(), map()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def update_puzzle(%Puzzle{} = puzzle, attrs) do + Multi.new() + |> Multi.update(:puzzle, Puzzle.changeset(puzzle, attrs)) + |> Multi.run(:validators, fn repo, %{puzzle: puzzle} -> + upsert_validators(repo, puzzle, Map.get(attrs, :validators, [])) + end) + |> Repo.transaction() + |> case do + {:ok, %{puzzle: puzzle}} -> {:ok, preload_assocs(puzzle)} + {:error, _step, changeset, _} -> {:error, changeset} + end + end + + @spec delete_puzzle(Puzzle.t()) :: {:ok, Puzzle.t()} | {:error, Ecto.Changeset.t()} + def delete_puzzle(%Puzzle{} = puzzle) do + Repo.delete(puzzle) + end + + @spec attach_metrics(Puzzle.t(), map()) :: + {:ok, PuzzleMetric.t()} | {:error, Ecto.Changeset.t()} + def attach_metrics(%Puzzle{id: puzzle_id}, attrs) do + %PuzzleMetric{puzzle_id: puzzle_id} + |> PuzzleMetric.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: :puzzle_id + ) + end + + defp upsert_validators(repo, puzzle, validators) when is_list(validators) do + repo.delete_all(from v in PuzzleValidator, where: v.puzzle_id == ^puzzle.id) + + validators + |> Enum.map(fn validator_attrs -> + validator_attrs + |> Map.put(:puzzle_id, puzzle.id) + |> PuzzleValidator.changeset(%PuzzleValidator{}) + end) + |> Enum.reduce_while({:ok, []}, fn + %Ecto.Changeset{valid?: true} = changeset, {:ok, acc} -> + case repo.insert(changeset) do + {:ok, validator} -> {:cont, {:ok, [validator | acc]}} + {:error, changeset} -> {:halt, {:error, changeset}} + end + + changeset, _ -> + {:halt, {:error, changeset}} + end) + end + + defp upsert_validators(_repo, _puzzle, _), do: {:ok, []} + + defp base_query do + from p in Puzzle, + preload: [:author, :validators, :metrics] + end + + defp maybe_preload(query, opts) do + case Keyword.get(opts, :preload) do + nil -> query + preload -> preload(query, ^preload) + end + end + + defp maybe_filter_by_author(query, opts) do + case Keyword.get(opts, :author_id) do + nil -> query + author_id -> where(query, [p], p.author_id == ^author_id) + end + end + + defp maybe_filter_by_tags(query, opts) do + case Keyword.get(opts, :tags) do + nil -> query + [] -> query + tags -> where(query, [p], fragment("tags && ?", ^tags)) + end + end + + defp maybe_filter_visibility(query, true), do: query + + defp maybe_filter_visibility(query, _include_private) do + where(query, [p], fragment("lower(?) = ?", p.visibility, ^"approved")) + end + + defp normalize_pagination(params) do + page = + params + |> fetch_param(:page, @default_page) + |> coerce_integer(@default_page) + |> max(@min_page) + + page_size = + params + |> fetch_param(:page_size, @default_page_size) + |> coerce_integer(@default_page_size) + |> max(@min_page_size) + |> min(@max_page_size) + + %{page: page, page_size: page_size} + end + + defp fetch_param(params, key, default) when is_map(params) do + Map.get(params, key, Map.get(params, to_string(key), default)) + end + + defp fetch_param(params, key, default) when is_list(params) do + Keyword.get(params, key, default) + end + + defp fetch_param(_params, _key, default), do: default + + defp coerce_integer(value, _default) when is_integer(value), do: value + + defp coerce_integer(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _rest} -> int + :error -> default + end + end + + defp coerce_integer(_value, default), do: default + + defp preload_assocs(puzzle) do + Repo.preload(puzzle, [:author, :validators, :metrics]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex new file mode 100644 index 00000000..d50f1b8d --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle.ex @@ -0,0 +1,104 @@ +defmodule CodincodApi.Puzzles.Puzzle do + @moduledoc """ + Puzzle domain schema capturing authoring information, difficulty, solution metadata and + moderation feedback. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator, PuzzleMetric, PuzzleTestCase, PuzzleExample} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzles" do + field :legacy_id, :string + field :title, :string + field :statement, :string + field :constraints, :string + field :difficulty, :string + field :visibility, :string + field :tags, {:array, :string}, default: [] + field :solution, :map, default: %{} # Deprecated: being normalized to test_cases/examples + field :moderation_feedback, :string + field :legacy_metrics_id, :string + field :legacy_comments, {:array, :string}, default: [] + + belongs_to :author, User + has_many :validators, PuzzleValidator + has_many :test_cases, PuzzleTestCase + has_many :examples, PuzzleExample + has_one :metrics, PuzzleMetric + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Puzzle authored by users for single-player or multiplayer experiences." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + title: String.t() | nil, + statement: String.t() | nil, + constraints: String.t() | nil, + difficulty: String.t() | nil, + visibility: String.t() | nil, + tags: [String.t()], + solution: map(), + moderation_feedback: String.t() | nil, + legacy_metrics_id: String.t() | nil, + legacy_comments: [String.t()], + author_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Base changeset for puzzle authoring. + """ + @spec changeset(Puzzle.t(), map()) :: Ecto.Changeset.t() + def changeset(puzzle, attrs) do + puzzle + |> cast(attrs, [ + :legacy_id, + :title, + :statement, + :constraints, + :difficulty, + :visibility, + :tags, + :solution, + :moderation_feedback, + :author_id + ]) + |> validate_required([:title, :difficulty, :visibility, :author_id]) + |> validate_length(:title, min: 4, max: 128) + |> validate_inclusion(:difficulty, [ + "BEGINNER", + "EASY", + "INTERMEDIATE", + "ADVANCED", + "HARD", + "EXPERT" + ]) + |> validate_inclusion(:visibility, [ + "DRAFT", + "READY", + "REVIEW", + "REVISE", + "APPROVED", + "INACTIVE", + "ARCHIVED" + ]) + |> put_default_solution() + end + + defp put_default_solution(changeset) do + update_change(changeset, :solution, fn + nil -> %{} + solution when is_map(solution) -> solution + _ -> %{} + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex new file mode 100644 index 00000000..40540eaa --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_example.ex @@ -0,0 +1,62 @@ +defmodule CodincodApi.Puzzles.PuzzleExample do + @moduledoc """ + Example schema for puzzle illustrations. + Examples help users understand puzzle requirements with sample inputs/outputs. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_examples" do + field :legacy_id, :string + field :input, :string + field :output, :string + field :explanation, :string + field :order, :integer + field :metadata, :map, default: %{} + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Example for illustrating puzzle behavior." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + output: String.t() | nil, + explanation: String.t() | nil, + order: integer() | nil, + metadata: map(), + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating or updating examples. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(example, attrs) do + example + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :input, + :output, + :explanation, + :order, + :metadata + ]) + |> validate_required([:puzzle_id, :input, :output, :order]) + |> validate_number(:order, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:puzzle_id) + |> unique_constraint(:legacy_id) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex new file mode 100644 index 00000000..e5fe95de --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_metric.ex @@ -0,0 +1,52 @@ +defmodule CodincodApi.Puzzles.PuzzleMetric do + @moduledoc """ + Aggregated statistics for a puzzle used by leaderboards and filtering. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_metrics" do + field :legacy_id, :string + field :attempt_count, :integer, default: 0 + field :success_count, :integer, default: 0 + field :average_execution_ms, :float, default: 0.0 + field :average_code_length, :integer, default: 0 + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Rolled-up statistics for puzzle performance insights." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + attempt_count: non_neg_integer() | nil, + success_count: non_neg_integer() | nil, + average_execution_ms: float() | nil, + average_code_length: integer() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(metric, attrs) do + metric + |> cast(attrs, [ + :legacy_id, + :attempt_count, + :success_count, + :average_execution_ms, + :average_code_length, + :puzzle_id + ]) + |> validate_required([:puzzle_id]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex new file mode 100644 index 00000000..446bd442 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_test_case.ex @@ -0,0 +1,62 @@ +defmodule CodincodApi.Puzzles.PuzzleTestCase do + @moduledoc """ + Test case schema for puzzle validation. + Each puzzle can have multiple test cases that validate submitted solutions. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_test_cases" do + field :legacy_id, :string + field :input, :string + field :expected_output, :string + field :is_sample, :boolean, default: false + field :order, :integer + field :metadata, :map, default: %{} + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Test case for validating puzzle solutions." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + expected_output: String.t() | nil, + is_sample: boolean(), + order: integer() | nil, + metadata: map(), + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @doc """ + Changeset for creating or updating test cases. + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(test_case, attrs) do + test_case + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :input, + :expected_output, + :is_sample, + :order, + :metadata + ]) + |> validate_required([:puzzle_id, :input, :expected_output, :order]) + |> validate_number(:order, greater_than_or_equal_to: 0) + |> foreign_key_constraint(:puzzle_id) + |> unique_constraint(:legacy_id) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex new file mode 100644 index 00000000..7536ed7f --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/puzzles/puzzle_validator.ex @@ -0,0 +1,43 @@ +defmodule CodincodApi.Puzzles.PuzzleValidator do + @moduledoc """ + Represents a single validator/test-case for a puzzle. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.Puzzles.Puzzle + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "puzzle_validators" do + field :legacy_id, :string + field :input, :string + field :output, :string + field :is_public, :boolean, default: false + + belongs_to :puzzle, Puzzle + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Test cases executed to verify puzzle solutions." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + input: String.t() | nil, + output: String.t() | nil, + is_public: boolean() | nil, + puzzle_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(validator, attrs) do + validator + |> cast(attrs, [:legacy_id, :input, :output, :is_public, :puzzle_id]) + |> validate_required([:input, :output, :puzzle_id]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/repo.ex b/libs/backend/codincod_api/lib/codincod_api/repo.ex new file mode 100644 index 00000000..a1a5210c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/repo.ex @@ -0,0 +1,5 @@ +defmodule CodincodApi.Repo do + use Ecto.Repo, + otp_app: :codincod_api, + adapter: Ecto.Adapters.Postgres +end diff --git a/libs/backend/codincod_api/lib/codincod_api/submissions.ex b/libs/backend/codincod_api/lib/codincod_api/submissions.ex new file mode 100644 index 00000000..09333943 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/submissions.ex @@ -0,0 +1,96 @@ +defmodule CodincodApi.Submissions do + @moduledoc """ + Submissions context providing persistence and query helpers for code submissions. + Mirrors the behaviour of `libs/backend/src/services/submission.service.ts`. + """ + + import Ecto.Query, warn: false + alias CodincodApi.Repo + + alias CodincodApi.Submissions.Submission + + @type submission_params :: map() + + @spec get_submission(Ecto.UUID.t(), keyword()) :: Submission.t() | nil + def get_submission(id, opts \\ []) do + Submission + |> Repo.get(id) + |> maybe_preload(opts) + end + + @spec get_submission!(Ecto.UUID.t()) :: Submission.t() + def get_submission!(id), do: Repo.get!(Submission, id) + + @spec fetch_submission(Ecto.UUID.t(), keyword()) :: {:ok, Submission.t()} | {:error, :not_found} + def fetch_submission(id, opts \\ []) do + case get_submission(id, opts) do + nil -> {:error, :not_found} + submission -> {:ok, submission} + end + end + + @spec list_by_user(Ecto.UUID.t(), keyword()) :: [Submission.t()] + def list_by_user(user_id, opts \\ []) do + Submission + |> where([s], s.user_id == ^user_id) + |> order_by([s], desc: s.inserted_at) + |> maybe_limit(opts) + |> preload([:puzzle, :programming_language, :game]) + |> Repo.all() + end + + @spec list_by_puzzle(Ecto.UUID.t()) :: [Submission.t()] + def list_by_puzzle(puzzle_id) do + Submission + |> where([s], s.puzzle_id == ^puzzle_id) + |> order_by([s], desc: s.inserted_at) + |> preload([:user, :programming_language]) + |> Repo.all() + end + + @spec create_submission(submission_params()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def create_submission(attrs) do + %Submission{} + |> Submission.create_changeset(attrs) + |> Repo.insert() + end + + @spec update_result(Submission.t(), map()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def update_result(%Submission{} = submission, attrs) do + submission + |> Submission.update_result_changeset(attrs) + |> Repo.update() + end + + @spec link_to_game(Submission.t(), Ecto.UUID.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + def link_to_game(%Submission{} = submission, game_id) do + submission + |> Ecto.Changeset.change(%{game_id: game_id}) + |> Repo.update() + end + + @spec delete_submissions([Ecto.UUID.t()]) :: {non_neg_integer(), nil} + def delete_submissions(ids) when is_list(ids) do + Repo.delete_all(from s in Submission, where: s.id in ^ids) + end + + defp maybe_preload(nil, _opts), do: nil + + defp maybe_preload(submission, opts) do + case Keyword.get(opts, :preload) do + nil -> submission + preloads -> Repo.preload(submission, preloads) + end + end + + defp maybe_limit(query, opts) do + case Keyword.get(opts, :limit) do + nil -> query + limit when is_integer(limit) and limit > 0 -> limit(query, ^limit) + _ -> query + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/submissions/evaluator.ex b/libs/backend/codincod_api/lib/codincod_api/submissions/evaluator.ex new file mode 100644 index 00000000..da72e785 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/submissions/evaluator.ex @@ -0,0 +1,147 @@ +defmodule CodincodApi.Submissions.Evaluator do + @moduledoc """ + Executes puzzle validators against the Piston service and collates the + resulting success metrics used when creating submissions. + """ + + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + + @type evaluation_summary :: %{ + passed: non_neg_integer(), + failed: non_neg_integer(), + total: non_neg_integer(), + success_rate: float(), + result: String.t() + } + + @type evaluation_result :: %{ + summary: evaluation_summary(), + responses: [{PuzzleValidator.t(), map()}] + } + + @default_timeout 20_000 + + @spec evaluate(String.t(), Puzzle.t(), ProgrammingLanguage.t(), keyword()) :: + {:ok, evaluation_result()} | {:error, term()} + def evaluate(code, %Puzzle{} = puzzle, %ProgrammingLanguage{} = language, opts \\ []) + when is_binary(code) do + validators = puzzle.validators || [] + + cond do + validators == [] -> + {:error, :no_validators} + + true -> + with {:ok, runtime} <- resolve_runtime(language), + {:ok, responses} <- run_validators(code, runtime, validators, opts), + {:ok, summary} <- summarise(responses) do + {:ok, %{summary: summary, responses: responses}} + end + end + end + + defp resolve_runtime(%ProgrammingLanguage{version: nil}) do + {:error, :missing_version} + end + + defp resolve_runtime(%ProgrammingLanguage{} = language) do + runtime_language = language.runtime || language.language + + if runtime_language do + {:ok, + %{ + language: runtime_language, + version: language.version + }} + else + {:error, :missing_runtime} + end + end + + defp run_validators(code, runtime, validators, opts) do + timeout = Keyword.get(opts, :timeout, @default_timeout) + concurrency = Keyword.get(opts, :max_concurrency, System.schedulers_online()) + + validators + |> Task.async_stream( + fn validator -> + inputs = build_request(runtime, code, validator) + + case CodincodApi.Piston.execute(inputs) do + {:ok, response} -> {:ok, {validator, response}} + {:error, reason} -> {:error, reason} + end + end, + timeout: timeout, + max_concurrency: concurrency, + ordered: true + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, result}}, {:ok, acc} -> {:cont, {:ok, [result | acc]}} + {:ok, {:error, reason}}, _ -> {:halt, {:error, reason}} + {:exit, reason}, _ -> {:halt, {:error, reason}} + end) + |> case do + {:ok, responses} -> {:ok, Enum.reverse(responses)} + {:error, reason} -> {:error, reason} + end + end + + defp build_request(runtime, code, validator) do + %{ + "language" => runtime.language, + "version" => runtime.version, + "files" => [%{"content" => code}], + "stdin" => validator.input || "" + } + end + + defp summarise(responses) when is_list(responses) do + total = length(responses) + + {passed, failed} = + Enum.reduce(responses, {0, 0}, fn {validator, response}, {p_acc, f_acc} -> + if successful?(validator, response) do + {p_acc + 1, f_acc} + else + {p_acc, f_acc + 1} + end + end) + + success_rate = if total > 0, do: passed / total, else: 0.0 + + summary = %{ + passed: passed, + failed: failed, + total: total, + success_rate: success_rate, + result: if(failed == 0 and total > 0, do: "success", else: "error") + } + + {:ok, summary} + end + + defp successful?(%PuzzleValidator{output: expected}, response) do + cond do + not is_map(response) -> + false + + is_integer(get_in(response, ["run", "code"])) and get_in(response, ["run", "code"]) != 0 -> + false + + true -> + actual_output = + (get_in(response, ["run", "output"]) || get_in(response, ["run", "stdout"]) || "") + |> to_string() + + compare_outputs(expected, actual_output) + end + end + + defp compare_outputs(nil, actual), do: String.trim_trailing(actual) == "" + + defp compare_outputs(expected, actual) do + String.trim_trailing(to_string(expected)) == String.trim_trailing(actual) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/submissions/submission.ex b/libs/backend/codincod_api/lib/codincod_api/submissions/submission.ex new file mode 100644 index 00000000..a1a49943 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/submissions/submission.ex @@ -0,0 +1,71 @@ +defmodule CodincodApi.Submissions.Submission do + @moduledoc """ + Submission schema storing the code, execution result and linkage to puzzles and games. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias CodincodApi.{Accounts.User, Puzzles.Puzzle} + alias CodincodApi.Games.Game + alias CodincodApi.Languages.ProgrammingLanguage + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "submissions" do + field :legacy_id, :string + field :code, :string + field :result, :map, default: %{} + field :score, :float + field :legacy_game_submission_id, :string + + belongs_to :puzzle, Puzzle + belongs_to :user, User + belongs_to :programming_language, ProgrammingLanguage + belongs_to :game, Game + + timestamps(type: :utc_datetime_usec) + end + + @typedoc "Code run submitted by a user for evaluation." + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + legacy_id: String.t() | nil, + code: String.t() | nil, + result: map(), + score: float() | nil, + legacy_game_submission_id: String.t() | nil, + puzzle_id: Ecto.UUID.t() | nil, + user_id: Ecto.UUID.t() | nil, + programming_language_id: Ecto.UUID.t() | nil, + game_id: Ecto.UUID.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(submission, attrs) do + submission + |> cast(attrs, [ + :legacy_id, + :puzzle_id, + :user_id, + :programming_language_id, + :game_id, + :code, + :result, + :score, + :legacy_game_submission_id + ]) + |> validate_required([:puzzle_id, :user_id, :programming_language_id, :code]) + |> validate_length(:code, min: 1) + end + + @spec update_result_changeset(t(), map()) :: Ecto.Changeset.t() + def update_result_changeset(submission, attrs) do + submission + |> cast(attrs, [:result, :score]) + |> validate_required([:result]) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api/typegen.ex b/libs/backend/codincod_api/lib/codincod_api/typegen.ex new file mode 100644 index 00000000..b6fb71fb --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api/typegen.ex @@ -0,0 +1,136 @@ +defmodule CodincodApi.Typegen do + @moduledoc """ + Utilities for generating TypeScript definitions that mirror the backend's + validation rules and response payloads. + + This generator focuses on the portions of the API that already exist in the + Phoenix migration (authentication and account preferences). As more routes are + migrated the generator can be extended with additional sections. + """ + + alias CodincodApi.Accounts.{Preference, User} + + @default_destination Path.expand( + Path.join([ + __DIR__, + "..", + "..", + "..", + "..", + "types", + "src", + "elixir-generated.ts" + ]) + ) + + @doc "Returns the default output path for the generated TypeScript file." + @spec default_destination() :: String.t() + def default_destination, do: @default_destination + + @doc "Generates the TypeScript file using the provided options." + @spec generate(keyword()) :: {:ok, String.t()} | {:error, term()} + def generate(opts \\ []) do + dest = + opts + |> Keyword.get(:dest, default_destination()) + |> Path.expand(File.cwd!()) + + data = %{ + auth: auth_config(), + preferences: preferences_config(), + generated_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + } + + content = render_typescript(data) + + with :ok <- File.mkdir_p(Path.dirname(dest)), + :ok <- File.write(dest, content) do + {:ok, dest} + end + end + + defp auth_config do + %{ + username: %{ + min_length: User.username_min_length(), + max_length: User.username_max_length(), + regex: User.username_regex() + }, + password: %{ + min_length: User.password_min_length() + }, + email: %{ + regex: User.email_regex() + } + } + end + + defp preferences_config do + %{ + theme_options: Preference.theme_options() + } + end + + defp render_typescript(%{auth: auth, preferences: prefs, generated_at: generated_at}) do + username_regex = regex_literal(auth.username.regex) + email_regex = regex_literal(auth.email.regex) + theme_options = format_array(prefs.theme_options) + + [ + "/* eslint-disable */", + "// Auto-generated by `mix codincod.gen_types`. Do not edit manually.", + "// Last generated: #{generated_at}", + "", + "export const AUTH_VALIDATION = {", + " username: {", + " minLength: #{auth.username.min_length},", + " maxLength: #{auth.username.max_length},", + " allowedCharacters: #{username_regex},", + " },", + " email: {", + " pattern: #{email_regex},", + " },", + " password: {", + " minLength: #{auth.password.min_length},", + " },", + "} as const;", + "", + "export const ACCOUNT_PREFERENCES = {", + " themeOptions: #{theme_options},", + "} as const;", + "", + "export type AccountPreferencesPayload = {", + " preferredLanguage: string | null;", + " theme: (typeof ACCOUNT_PREFERENCES.themeOptions)[number] | null;", + " blockedUsers: string[];", + " editor: Record;", + "};", + "", + "export type AccountPreferencesResponse = AccountPreferencesPayload;", + "" + ] + |> Enum.join("\n") + end + + defp regex_literal(%Regex{} = regex) do + source = Regex.source(regex) |> String.replace("/", "\\/") + opts = Regex.opts(regex) + + if opts == "" do + "/#{source}/" + else + "/#{source}/#{opts}" + end + end + + defp format_array([]), do: "[]" + + defp format_array(list) when is_list(list) do + values = + list + |> Enum.map(&inspect/1) + |> Enum.join(", ") + + "[#{values}]" + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web.ex b/libs/backend/codincod_api/lib/codincod_api_web.ex new file mode 100644 index 00000000..b2fbf967 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web.ex @@ -0,0 +1,65 @@ +defmodule CodincodApiWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use CodincodApiWeb, :controller + use CodincodApiWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + use Gettext, backend: CodincodApiWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: CodincodApiWeb.Endpoint, + router: CodincodApiWeb.Router, + statics: CodincodApiWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex new file mode 100644 index 00000000..220967b9 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/auth/error_handler.ex @@ -0,0 +1,23 @@ +defmodule CodincodApiWeb.Auth.ErrorHandler do + @moduledoc """ + Handles authentication errors for Guardian. + """ + import Plug.Conn + + @behaviour Guardian.Plug.ErrorHandler + + @impl Guardian.Plug.ErrorHandler + def auth_error(conn, {type, _reason}, _opts) do + body = Jason.encode!(%{error: to_string(type), message: error_message(type)}) + + conn + |> put_resp_content_type("application/json") + |> send_resp(401, body) + end + + defp error_message(:invalid_token), do: "Invalid authentication token" + defp error_message(:token_expired), do: "Authentication token has expired" + defp error_message(:no_resource_found), do: "User not found" + defp error_message(:unauthenticated), do: "Authentication required" + defp error_message(_), do: "Authentication failed" +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/auth/guardian.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/guardian.ex new file mode 100644 index 00000000..8592c855 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/auth/guardian.ex @@ -0,0 +1,53 @@ +defmodule CodincodApiWeb.Auth.Guardian do + @moduledoc """ + Guardian implementation for JWT authentication. + Handles encoding/decoding of tokens and user resource management. + """ + use Guardian, otp_app: :codincod_api + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + + @doc """ + Encodes the user ID as the subject claim. + """ + def subject_for_token(%User{id: id}, _claims) do + {:ok, to_string(id)} + end + + def subject_for_token(_, _) do + {:error, :invalid_resource} + end + + @doc """ + Retrieves the user from the subject claim. + """ + def resource_from_claims(%{"sub" => id}) do + case Accounts.get_user(id) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + + def resource_from_claims(_claims) do + {:error, :invalid_claims} + end + + @doc """ + Generates a JWT token for a user with custom claims. + """ + def generate_token(user, token_type \\ :access) do + claims = %{ + "typ" => Atom.to_string(token_type), + "username" => user.username, + "role" => user.role + } + + encode_and_sign(user, claims, ttl: get_ttl(token_type)) + end + + defp get_ttl(:access), do: {7, :days} + defp get_ttl(:refresh), do: {30, :days} + defp get_ttl(:password_reset), do: {1, :hour} + defp get_ttl(:email_confirmation), do: {24, :hours} +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex b/libs/backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex new file mode 100644 index 00000000..8b790174 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/auth/pipeline.ex @@ -0,0 +1,13 @@ +defmodule CodincodApiWeb.Auth.Pipeline do + @moduledoc """ + Guardian authentication pipeline for protected routes. + """ + use Guardian.Plug.Pipeline, + otp_app: :codincod_api, + module: CodincodApiWeb.Auth.Guardian, + error_handler: CodincodApiWeb.Auth.ErrorHandler + + plug Guardian.Plug.VerifyHeader, scheme: "Bearer" + plug Guardian.Plug.EnsureAuthenticated + plug Guardian.Plug.LoadResource +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex b/libs/backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex new file mode 100644 index 00000000..a6381a51 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/channels/game_channel.ex @@ -0,0 +1,265 @@ +defmodule CodincodApiWeb.GameChannel do + @moduledoc """ + Phoenix Channel for real-time multiplayer game communication. + + Handles: + - Player joining/leaving + - Code updates during gameplay + - Submission results broadcasting + - Turn-based coordination + - Game state synchronization + """ + + use CodincodApiWeb, :channel + require Logger + + alias CodincodApi.{Games, Accounts} + alias CodincodApi.Games.Game + + @impl true + def join("game:" <> game_id, payload, socket) do + Logger.debug("Attempting to join game channel: game:#{game_id}") + + with {:ok, game_uuid} <- parse_uuid(game_id), + {:ok, user_id} <- get_user_id(payload, socket), + {:ok, user} <- Accounts.fetch_user(user_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- verify_player_in_game(game, user_id) do + # Track user presence + send(self(), :after_join) + + socket = + socket + |> assign(:game_id, game_uuid) + |> assign(:game, game) + |> assign(:user_id, user_id) + |> assign(:username, user.username) + + {:ok, %{game: serialize_game(game), userId: user_id}, socket} + else + {:error, :invalid_uuid} -> + {:error, %{reason: "Invalid game ID"}} + + {:error, :not_in_game} -> + {:error, %{reason: "You are not a player in this game"}} + + {:error, :not_found} -> + {:error, %{reason: "Game not found"}} + + {:error, :unauthorized} -> + {:error, %{reason: "Authentication required"}} + + error -> + Logger.error("Failed to join game channel: #{inspect(error)}") + {:error, %{reason: "Failed to join game"}} + end + end + + @impl true + def handle_info(:after_join, socket) do + _game_id = socket.assigns.game_id + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Announce player presence to others + broadcast_from!(socket, "player_online", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + # Track presence + push(socket, "presence_state", %{}) + + {:noreply, socket} + end + + ## Incoming events + + @impl true + def handle_in("code_update", %{"code" => code, "language" => language}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast code changes to other players (for spectating/collaborative modes) + broadcast_from!(socket, "player_code_updated", %{ + userId: user_id, + username: username, + code: code, + language: language, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("submission_result", payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast submission results to all players + broadcast!(socket, "player_submitted", %{ + userId: user_id, + username: username, + status: payload["status"], + executionTime: payload["executionTime"], + timestamp: DateTime.utc_now() + }) + + # Check if game should end (first to solve or all submitted) + check_game_completion(socket) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("ready", _payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Announce player is ready + broadcast!(socket, "player_ready", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("chat_message", %{"message" => message}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + if String.trim(message) != "" && String.length(message) <= 500 do + broadcast!(socket, "chat_message", %{ + userId: user_id, + username: username, + message: String.trim(message), + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + else + {:reply, {:error, %{reason: "Invalid message"}}, socket} + end + end + + @impl true + def handle_in("request_hint", _payload, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast hint request (may consume hint credits) + broadcast!(socket, "hint_requested", %{ + userId: user_id, + username: username, + timestamp: DateTime.utc_now() + }) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("typing", %{"isTyping" => is_typing}, socket) do + user_id = socket.assigns.user_id + username = socket.assigns.username + + # Broadcast typing indicator + broadcast_from!(socket, "player_typing", %{ + userId: user_id, + username: username, + isTyping: is_typing + }) + + {:noreply, socket} + end + + # Catch-all for unknown events + @impl true + def handle_in(event, _payload, socket) do + Logger.warning("Unknown game channel event: #{event}") + {:reply, {:error, %{reason: "Unknown event"}}, socket} + end + + ## Private functions + + defp get_user_id(%{"userId" => user_id}, _socket) when is_binary(user_id) do + parse_uuid(user_id) + end + + defp get_user_id(_payload, socket) do + # Try to get from socket assigns (set by authentication) + case socket.assigns[:current_user_id] do + nil -> {:error, :unauthorized} + user_id -> {:ok, user_id} + end + end + + defp verify_player_in_game(%Game{players: players}, user_id) do + if Enum.any?(players, fn p -> p.user_id == user_id end) do + :ok + else + {:error, :not_in_game} + end + end + + defp check_game_completion(socket) do + game_id = socket.assigns.game_id + + # Reload game to check current state + game = Games.get_game!(game_id, preload: [:owner, :puzzle, players: :user]) + + # Logic to determine if game is complete + # This could check if: + # - Someone has finished (first to finish mode) + # - All players have submitted + # - Time limit reached + # For now, we'll just broadcast game state + broadcast!(socket, "game_state_updated", %{ + status: game.status, + timestamp: DateTime.utc_now() + }) + end + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty, + description: game.puzzle.description + }, + players: + Enum.map(game.players, fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex b/libs/backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex new file mode 100644 index 00000000..0eea4ee8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/channels/user_socket.ex @@ -0,0 +1,30 @@ +defmodule CodincodApiWeb.UserSocket do + @moduledoc """ + WebSocket endpoint for real-time features. + """ + + use Phoenix.Socket + + # Channels + channel "game:*", CodincodApiWeb.GameChannel + + @impl true + def connect(%{"token" => token}, socket, _connect_info) do + # Verify JWT token and extract user_id + case CodincodApiWeb.Auth.Guardian.decode_and_verify(token) do + {:ok, claims} -> + user_id = claims["sub"] + {:ok, assign(socket, :current_user_id, user_id)} + + {:error, _reason} -> + :error + end + end + + def connect(_params, _socket, _connect_info) do + :error + end + + @impl true + def id(socket), do: "user_socket:#{socket.assigns.current_user_id}" +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex new file mode 100644 index 00000000..2228945d --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_controller.ex @@ -0,0 +1,284 @@ +defmodule CodincodApiWeb.AccountController do + @moduledoc """ + Account endpoints mirroring the Fastify account routes (`/account`). + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Accounts, Games, Metrics, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Games.Game + alias CodincodApi.Metrics.UserMetric + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @profile_schema %{ + "bio" => {:string, 0, 500}, + "location" => {:string, 0, 100}, + "picture" => :string_url, + "socials" => :string_url_list + } + @profile_fields Map.keys(@profile_schema) + + tags(["Account"]) + + operation(:show, + summary: "Current account status", + responses: %{ + 200 => {"Authenticated account", "application/json", Schemas.Account.StatusResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Account.StatusResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, _params) do + with %User{id: user_id, username: username} <- conn.assigns[:current_user], + %User{} = user <- Accounts.get_user!(user_id) do + json(conn, %{ + isAuthenticated: true, + userId: user.id, + username: username, + role: user.role + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{isAuthenticated: false, message: "Not authenticated"}) + end + end + + operation(:update_profile, + summary: "Update profile", + request_body: + {"Profile properties", "application/json", Schemas.Account.ProfileUpdateRequest}, + responses: %{ + 200 => {"Profile updated", "application/json", Schemas.Account.ProfileUpdateResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def update_profile(conn, params) do + with %User{} = current_user <- conn.assigns[:current_user], + {:ok, updates} <- normalize_profile_params(params), + {:ok, %User{} = user} <- Accounts.update_profile(current_user, %{profile: updates}) do + json(conn, %{message: "Profile updated successfully", profile: user.profile}) + else + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid profile payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to update profile", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + end + end + + operation(:leaderboard_rank, + summary: "Get current user's leaderboard ranking", + responses: %{ + 200 => {"User ranking", "application/json", Schemas.Leaderboard.UserRankResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def leaderboard_rank(conn, _params) do + with %User{id: user_id} <- conn.assigns[:current_user] do + metric = Metrics.get_user_metric(user_id) + + rank = + if metric do + calculate_user_rank(user_id, metric.rating) + else + nil + end + + conn + |> put_status(:ok) + |> json(%{ + userId: user_id, + rank: rank, + rating: metric && metric.rating, + puzzlesSolved: metric && metric.puzzles_solved, + totalSubmissions: metric && metric.total_submissions + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + end + end + + operation(:games, + summary: "Get games for current user", + responses: %{ + 200 => {"User games", "application/json", Schemas.Games.UserGamesResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def games(conn, _params) do + with %User{id: user_id} <- conn.assigns[:current_user] do + user_games = Games.list_games_for_user(user_id) + + conn + |> put_status(:ok) + |> json(%{ + games: Enum.map(user_games, &serialize_game/1), + count: length(user_games) + }) + else + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + end + end + + ## Private helper functions + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: + game.owner && + %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: + game.puzzle && + %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty + }, + players: + Enum.map(game.players || [], fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + createdAt: game.inserted_at, + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp calculate_user_rank(user_id, rating) do + # Count how many users have a higher rating + count = + UserMetric + |> where([m], m.rating > ^rating or (m.rating == ^rating and m.user_id < ^user_id)) + |> Repo.aggregate(:count) + + count + 1 + end + + defp normalize_profile_params(params) when is_map(params) do + params + |> Enum.reduce_while(%{}, fn + {key, value}, acc when key in @profile_fields -> + case validate_profile_field(key, value) do + {:ok, normalized} -> {:cont, Map.put(acc, key, normalized)} + {:error, reason} -> {:halt, {:error, reason}} + end + + {_key, _value}, acc -> + {:cont, acc} + end) + |> case do + {:error, reason} -> {:error, :invalid_payload, reason} + result -> {:ok, result} + end + end + + defp normalize_profile_params(_), + do: {:error, :invalid_payload, %{message: "Expected JSON object"}} + + defp validate_profile_field("bio", value), do: validate_string(value, 0, 500, "bio") + defp validate_profile_field("location", value), do: validate_string(value, 0, 100, "location") + + defp validate_profile_field("picture", value) when value in [nil, ""], do: {:ok, value} + + defp validate_profile_field("picture", value) do + if valid_url?(value) do + {:ok, value} + else + {:error, %{field: "picture", message: "must be a valid URL"}} + end + end + + defp validate_profile_field("socials", value) when is_list(value) do + urls = Enum.with_index(value) + + case Enum.reduce_while(urls, [], fn {url, index}, acc -> + if valid_url?(url) do + {:cont, [url | acc]} + else + {:halt, + {:error, %{field: "socials", index: index, message: "must contain valid URLs"}}} + end + end) do + {:error, reason} -> {:error, reason} + urls -> {:ok, Enum.reverse(urls)} + end + end + + defp validate_profile_field("socials", _value), + do: {:error, %{field: "socials", message: "must be an array of URLs"}} + + defp validate_profile_field(_key, _value), do: {:ok, nil} + + defp validate_string(value, min, max, field) when is_binary(value) do + if String.length(value) <= max and String.length(value) >= min do + {:ok, value} + else + {:error, %{field: field, message: "must be between #{min} and #{max} characters"}} + end + end + + defp validate_string(nil, _min, _max, _field), do: {:ok, nil} + defp validate_string("", _min, _max, _field), do: {:ok, ""} + + defp validate_string(_value, _min, _max, field), + do: {:error, %{field: field, message: "must be a string"}} + + defp valid_url?(value) do + case URI.parse(value) do + %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) -> + true + + _ -> + false + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex new file mode 100644 index 00000000..002a84a8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/account_preference_controller.ex @@ -0,0 +1,229 @@ +defmodule CodincodApiWeb.AccountPreferenceController do + @moduledoc """ + Handles account preference endpoints mirroring the Fastify implementation. + + Supports full replacement (PUT), partial updates (PATCH), retrieval and + deletion of the authenticated user's preferences. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.{Preference, User} + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + tags(["Account Preferences"]) + + operation(:show, + summary: "Get account preferences", + responses: %{ + 200 => {"Preferences", "application/json", Schemas.Account.PreferencesPayload}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, _params) do + with %User{} = user <- conn.assigns[:current_user], + %Preference{} = preference <- Accounts.get_preferences(user) do + json(conn, serialize(preference)) + else + %User{} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + @spec replace(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:replace, + summary: "Replace preferences", + request_body: + {"Preferences payload", "application/json", Schemas.Account.PreferencesPayload, + required: true}, + responses: %{ + 200 => {"Updated preferences", "application/json", Schemas.Account.PreferencesPayload}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def replace(conn, params) do + persist_preferences(conn, params, :replace) + end + + @spec patch(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:patch, + summary: "Patch preferences", + request_body: {"Partial preferences", "application/json", Schemas.Account.PreferencesPayload}, + responses: %{ + 200 => {"Updated preferences", "application/json", Schemas.Account.PreferencesPayload}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def patch(conn, params) do + persist_preferences(conn, params, :patch) + end + + @spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation(:delete, + summary: "Delete preferences", + responses: %{ + 204 => {"Preferences deleted", "application/json", nil}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def delete(conn, _params) do + with %User{} = user <- conn.assigns[:current_user], + :ok <- Accounts.delete_preferences(user) do + send_resp(conn, :no_content, "") + else + %User{} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Preferences not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to delete preferences", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + defp persist_preferences(conn, params, _mode) do + with %User{} = user <- conn.assigns[:current_user], + {:ok, attrs} <- normalize_params(params), + {:ok, %Preference{} = preference} <- Accounts.upsert_preferences(user, attrs) do + json(conn, serialize(preference)) + else + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid payload", errors: errors}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to save preferences", errors: translate_errors(changeset)}) + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Invalid credentials"}) + end + end + + defp normalize_params(params) when is_map(params) do + theme_options = Preference.theme_options() + + Enum.reduce(params, {:ok, %{}}, fn + {"preferredLanguage", value}, {:ok, acc} when is_binary(value) or is_nil(value) -> + {:ok, Map.put(acc, :preferred_language, value)} + + {"preferredLanguage", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "preferredLanguage", message: "must be a string"}]} + + {"theme", value}, {:ok, acc} when is_binary(value) -> + if value in theme_options do + {:ok, Map.put(acc, :theme, value)} + else + {:error, :invalid_payload, + [%{field: "theme", message: "must be one of #{Enum.join(theme_options, ", ")}"}]} + end + + {"theme", nil}, {:ok, acc} -> + {:ok, Map.put(acc, :theme, nil)} + + {"theme", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "theme", message: "must be a string or null"}]} + + {"blockedUsers", value}, {:ok, acc} when is_list(value) -> + with {:ok, ids} <- cast_blocked_users(value) do + {:ok, Map.put(acc, :blocked_user_ids, ids)} + else + {:error, error} -> {:error, :invalid_payload, [error]} + end + + {"blockedUsers", nil}, {:ok, acc} -> + {:ok, Map.put(acc, :blocked_user_ids, [])} + + {"blockedUsers", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "blockedUsers", message: "must be an array"}]} + + {"editor", value}, {:ok, acc} when is_map(value) or is_nil(value) -> + {:ok, Map.put(acc, :editor, value || %{})} + + {"editor", _value}, {:ok, _acc} -> + {:error, :invalid_payload, [%{field: "editor", message: "must be an object"}]} + + {_other, _value}, {:ok, acc} -> + {:ok, acc} + + {_key, _value}, {:error, reason, errors} -> + {:error, reason, errors} + end) + |> case do + {:ok, attrs} when map_size(attrs) > 0 -> {:ok, attrs} + {:ok, _} -> {:error, :invalid_payload, [%{field: nil, message: "No changes provided"}]} + {:error, reason, errors} -> {:error, reason, errors} + end + end + + defp normalize_params(_), + do: {:error, :invalid_payload, [%{field: nil, message: "Expected JSON object"}]} + + defp cast_blocked_users(values) do + values + |> Enum.reduce_while({:ok, []}, fn value, {:ok, acc} -> + case Ecto.UUID.cast(value) do + {:ok, uuid} -> + {:cont, {:ok, [uuid | acc]}} + + :error -> + {:halt, {:error, %{field: "blockedUsers", message: "must contain valid UUID strings"}}} + end + end) + |> case do + {:ok, ids} -> {:ok, Enum.reverse(ids)} + {:error, reason} -> {:error, reason} + end + end + + defp serialize(%Preference{} = preference) do + %{ + preferredLanguage: preference.preferred_language, + theme: preference.theme, + blockedUsers: Enum.map(preference.blocked_user_ids || [], & &1), + editor: preference.editor || %{} + } + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex new file mode 100644 index 00000000..74ec32f8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/auth_controller.ex @@ -0,0 +1,328 @@ +defmodule CodincodApiWeb.AuthController do + @moduledoc """ + Authentication endpoints mirroring the legacy Fastify routes. + + Handles user registration, login, logout, and token refresh while keeping the + token delivery mechanism (HTTP-only cookie) compatible with the existing + frontend expectations. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + require Logger + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + alias CodincodApiWeb.Auth.Guardian + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @token_cookie Application.compile_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:name, "token") + @cookie_max_age Application.compile_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:max_age, 7 * 24 * 60 * 60) + + tags(["Auth"]) + + operation(:register, + summary: "Register new user", + request_body: + {"Registration payload", "application/json", Schemas.Auth.RegisterRequest, required: true}, + responses: %{ + 200 => {"Registration success", "application/json", Schemas.Auth.MessageResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec register(Plug.Conn.t(), map()) :: Plug.Conn.t() + def register(conn, params) do + require Logger + + attrs = %{ + username: Map.get(params, "username"), + email: Map.get(params, "email"), + password: Map.get(params, "password"), + password_confirmation: + Map.get(params, "passwordConfirmation") || Map.get(params, "password_confirmation") + } + + # Log registration attempt (without sensitive data) + Logger.info("Registration attempt for username: #{attrs.username}, email: #{attrs.email}") + + with {:ok, %User{} = user} <- Accounts.register_user(attrs), + {:ok, token, _claims} <- Guardian.generate_token(user) do + Logger.info("User registered successfully: #{user.username} (#{user.id})") + + conn + |> put_auth_cookie(token) + |> put_status(:ok) + |> json(%{message: "User registered successfully"}) + else + {:error, %Ecto.Changeset{} = changeset} -> + errors = translate_errors(changeset) + + # Log validation errors for debugging + Logger.warning("Registration validation failed for #{attrs.username}: #{inspect(errors)}") + + # Provide user-friendly error messages + message = + cond do + Map.has_key?(errors, :username) -> "Username validation failed" + Map.has_key?(errors, :email) -> "Email validation failed" + Map.has_key?(errors, :password) -> "Password validation failed" + true -> "Registration validation failed" + end + + conn + |> put_status(:bad_request) + |> json(%{ + message: message, + errors: errors + }) + + {:error, reason} -> + # Log the actual error for debugging + Logger.error("Registration failed for #{attrs.username}: #{inspect(reason)}") + + conn + |> put_status(:internal_server_error) + |> json(%{ + message: "Registration failed. Please try again later.", + error: "INTERNAL_ERROR" + }) + end + end + + operation(:login, + summary: "Authenticate user", + request_body: {"Credentials", "application/json", Schemas.Auth.LoginRequest, required: true}, + responses: %{ + 200 => {"Login success", "application/json", Schemas.Auth.MessageResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec login(Plug.Conn.t(), map()) :: Plug.Conn.t() + def login(conn, params) do + identifier = Map.get(params, "identifier") + password = Map.get(params, "password") + + cond do + !valid_identifier?(identifier) -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username or email"}) + + !is_binary(password) or password == "" -> + conn + |> put_status(:bad_request) + |> json(%{message: "Password is required"}) + + true -> + do_login(conn, identifier, password) + end + end + + defp do_login(conn, identifier, password) do + case Accounts.authenticate(identifier, password) do + {:ok, %User{} = user} -> + with {:ok, token, claims} <- Guardian.generate_token(user) do + require Logger + Logger.info("=== LOGIN TOKEN GENERATED ===") + Logger.info("User ID: #{user.id}") + Logger.info("Token (first 50 chars): #{String.slice(token, 0..50)}...") + Logger.info("Claims: #{inspect(claims)}") + Logger.info("Cookie name: #{@token_cookie}") + Logger.info("Setting cookie with options: #{inspect(cookie_options(:set))}") + Logger.info("============================") + + conn + |> put_auth_cookie(token) + |> tap(fn conn -> + Logger.info("Response cookies being set: #{inspect(conn.resp_cookies)}") + end) + |> put_status(:ok) + |> json(%{message: "Login successful"}) + else + {:error, reason} -> + Logger.error("Failed to generate token: #{inspect(reason)}") + conn + |> put_status(:internal_server_error) + |> json(%{message: "Failed to generate token", reason: inspect(reason)}) + end + + {:error, :invalid_credentials} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Invalid email/username or password"}) + + {:error, :banned} -> + conn + |> put_status(:forbidden) + |> json(%{message: "User is banned"}) + end +end + + operation(:logout, + summary: "Logout current user", + responses: %{ + 200 => {"Logout success", "application/json", Schemas.Auth.MessageResponse} + } + ) + + @spec logout(Plug.Conn.t(), map()) :: Plug.Conn.t() + def logout(conn, _params) do + conn + |> clear_auth_cookie() + |> put_status(:ok) + |> json(%{message: "Logout successful"}) + end + + operation(:refresh, + summary: "Refresh authentication token", + responses: %{ + 200 => {"Token refreshed", "application/json", Schemas.Auth.MessageResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec refresh(Plug.Conn.t(), map()) :: Plug.Conn.t() + def refresh(conn, _params) do + case conn.assigns[:current_user] do + %User{} = user -> + with {:ok, token, _claims} <- Guardian.generate_token(user) do + conn + |> put_auth_cookie(token) + |> put_status(:ok) + |> json(%{message: "Token refreshed"}) + else + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{message: "Failed to refresh token", reason: inspect(reason)}) + end + + _ -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Authentication required"}) + end + end + + defp valid_identifier?(identifier) when is_binary(identifier) do + username_regex = User.username_regex() + email_regex = User.email_regex() + username_min = User.username_min_length() + username_max = User.username_max_length() + + identifier != "" and + (Regex.match?(email_regex, identifier) or + (Regex.match?(username_regex, identifier) and + String.length(identifier) in username_min..username_max)) + end + + defp valid_identifier?(_), do: false + + defp put_auth_cookie(conn, token) do + Plug.Conn.put_resp_cookie(conn, @token_cookie, token, cookie_options(:set)) + end + + defp clear_auth_cookie(conn) do + Plug.Conn.delete_resp_cookie(conn, @token_cookie, cookie_options(:delete)) + end + + defp cookie_options(:set) do + base_cookie_options() + |> Keyword.put(:max_age, @cookie_max_age) + end + + defp cookie_options(:delete) do + base_cookie_options() + end + + defp base_cookie_options do + prod? = production?() + + # In development, use SameSite=None to allow cross-origin cookies + # (frontend on :5173, backend on :4000) + # In production, use SameSite=None with Secure for cross-domain + options = [ + path: "/", + http_only: true, + # Secure must be true when SameSite=None, browsers allow this for localhost + secure: true, + same_site: "None" + ] + + options + |> maybe_put_domain(prod?) + end + + defp maybe_put_domain(options, true) do + case System.get_env("FRONTEND_HOST") do + host when is_binary(host) and host != "" -> Keyword.put(options, :domain, host) + _ -> options + end + end + + defp maybe_put_domain(options, _), do: options + + defp production? do + Application.get_env(:codincod_api, :runtime_env, :dev) == :prod + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + |> enhance_error_messages() + end + + # Enhance error messages for better UX + defp enhance_error_messages(errors) do + errors + |> Enum.map(fn {field, messages} -> + enhanced = + Enum.map(messages, fn msg -> + case {field, msg} do + {:username, "has already been taken"} -> + "This username is already registered. Please choose a different username." + + {:email, "has already been taken"} -> + "This email address is already registered. Please use a different email or try logging in." + + {:password, "should be at least " <> _} -> + "Password must be at least 14 characters long for security." + + {:password_confirmation, "does not match confirmation"} -> + "Password confirmation does not match. Please ensure both passwords are identical." + + {:username, "has invalid format"} -> + "Username can only contain letters, numbers, hyphens, and underscores." + + {:username, "should be at least " <> _} -> + "Username must be at least 3 characters long." + + {:username, "should be at most " <> _} -> + "Username cannot be longer than 20 characters." + + {:email, "has invalid format"} -> + "Please enter a valid email address." + + {_, msg} -> + msg + end + end) + + {field, enhanced} + end) + |> Enum.into(%{}) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex new file mode 100644 index 00000000..deaddaf3 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/comment_controller.ex @@ -0,0 +1,166 @@ +defmodule CodincodApiWeb.CommentController do + @moduledoc """ + Handles comment retrieval, deletion, and voting endpoints. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Comments + alias CodincodApi.Comments.Comment + alias CodincodApi.Accounts.User + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @preloads [ + author: [], + children: [author: []] + ] + + operation(:show, + summary: "Get comment by ID", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: [ + ok: {"Comment details", "application/json", Schemas.Comment.CommentResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def show(conn, %{"id" => id}) do + comment = Comments.get_comment!(id, preload: @preloads) + json(conn, serialize_comment(comment)) + end + + operation(:delete, + summary: "Delete a comment", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: [ + no_content: "Comment deleted successfully", + forbidden: {"Not authorized to delete this comment", "application/json", Schemas.Common.ErrorResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def delete(conn, %{"id" => id}) do + with %Comment{} = comment <- Comments.get_comment!(id, preload: [:author]), + %User{} = current_user <- conn.assigns[:current_user], + :ok <- authorize_comment_delete(comment, current_user), + {:ok, _comment} <- Comments.soft_delete(comment) do + send_resp(conn, :no_content, "") + else + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You cannot delete this comment"}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + operation(:vote, + summary: "Vote on a comment", + parameters: [ + id: [ + in: :path, + description: "Comment ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Vote request", "application/json", Schemas.Comment.VoteRequest}, + responses: [ + ok: {"Updated comment with vote", "application/json", Schemas.Comment.CommentResponse}, + bad_request: {"Invalid vote type", "application/json", Schemas.Common.ErrorResponse}, + not_found: {"Comment not found", "application/json", Schemas.Common.ErrorResponse}, + unprocessable_entity: {"Unable to process vote", "application/json", Schemas.Common.ErrorResponse} + ] + ) + + def vote(conn, %{"id" => id} = params) do + with %Comment{} = comment <- Comments.get_comment!(id), + %User{id: user_id} <- conn.assigns[:current_user], + {:ok, vote_type} <- extract_vote_type(conn.body_params, params), + {:ok, %Comment{} = updated} <- Comments.toggle_vote(comment, user_id, vote_type) do + json(conn, serialize_comment(updated)) + else + {:error, {:invalid_vote_type, _}} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid vote type", allowed: ["upvote", "downvote"]}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to update vote", errors: translate_errors(changeset)}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + defp authorize_comment_delete(%Comment{author_id: author_id}, %User{id: user_id, role: role}) do + if author_id == user_id or role in ["moderator", "admin"] do + :ok + else + {:error, :forbidden} + end + end + + defp extract_vote_type(%{"type" => type}, _params) when type in ["upvote", "downvote"], + do: {:ok, type} + + defp extract_vote_type(_body_params, %{"type" => type}) when type in ["upvote", "downvote"], + do: {:ok, type} + + defp extract_vote_type(_, _), do: {:error, {:invalid_vote_type, nil}} + + defp serialize_comment(%Comment{} = comment) do + %{ + id: comment.id, + body: comment.body, + commentType: comment.comment_type, + upvote: comment.upvote_count, + downvote: comment.downvote_count, + authorId: comment.author_id, + puzzleId: comment.puzzle_id, + submissionId: comment.submission_id, + parentCommentId: comment.parent_comment_id, + deletedAt: comment.deleted_at, + insertedAt: comment.inserted_at, + updatedAt: comment.updated_at, + author: serialize_author(comment.author), + children: Enum.map(comment.children || [], &serialize_comment/1) + } + end + + defp serialize_author(%User{} = user) do + %{ + id: user.id, + username: user.username, + role: user.role + } + end + + defp serialize_author(_), do: nil + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex new file mode 100644 index 00000000..9eaaca75 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule CodincodApiWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex new file mode 100644 index 00000000..48f76181 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/execute_controller.ex @@ -0,0 +1,190 @@ +defmodule CodincodApiWeb.ExecuteController do + @moduledoc """ + Handles code execution without persistence, allowing users to test code + against custom inputs before creating submissions. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts.User + alias CodincodApi.Piston + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Execute"]) + + operation(:create, + summary: "Execute code without saving", + description: "Runs code against Piston with custom test input/output for validation", + request_body: {"Execute request", "application/json", Schemas.Execute.ExecuteRequest}, + responses: %{ + 200 => {"Execution result", "application/json", Schemas.Execute.ExecuteResponse}, + 400 => {"Invalid request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 503 => {"Service unavailable", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_execute_params(params), + {:ok, runtimes} <- Piston.list_runtimes(), + {:ok, runtime} <- find_runtime(runtimes, attrs.language), + {:ok, execution_result} <- execute_code(runtime, attrs) do + result = calculate_result(execution_result, attrs.test_output) + + response = %{ + run: execution_result["run"], + compile: execution_result["compile"], + puzzleResultInformation: result + } + + conn + |> put_status(:ok) + |> json(response) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid execution payload", errors: errors}) + + {:error, :runtime_not_found} -> + conn + |> put_status(:bad_request) + |> json(%{ + error: "Unsupported language", + message: "At the moment we don't support this language." + }) + + {:error, :service_unavailable} -> + conn + |> put_status(:service_unavailable) + |> json(%{ + error: "Internal server error", + message: "Network error occurred" + }) + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: "Internal server error", + message: "Something went wrong", + reason: inspect(reason) + }) + end + end + + defp normalize_execute_params(params) when is_map(params) do + {code, errors} = validate_required_string(Map.get(params, "code"), "code") + {language, errors} = validate_required_string(Map.get(params, "language"), "language", errors) + test_input = Map.get(params, "testInput", "") + test_output = Map.get(params, "testOutput", "") + + if errors == [] do + {:ok, + %{ + code: code, + language: language, + test_input: test_input, + test_output: test_output + }} + else + {:error, :invalid_payload, errors} + end + end + + defp normalize_execute_params(_params), do: {:error, :invalid_payload, []} + + defp validate_required_string(value, field, errors \\ []) + + defp validate_required_string(value, field, errors) when is_binary(value) do + if String.trim(value) == "" do + {nil, [%{field: field, message: "cannot be empty"} | errors]} + else + {value, errors} + end + end + + defp validate_required_string(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp find_runtime(runtimes, language) when is_list(runtimes) and is_binary(language) do + normalized = String.downcase(language) + + runtime = + Enum.find(runtimes, fn rt -> + runtime_lang = Map.get(rt, "language") || Map.get(rt, :language) + runtime_lang && String.downcase(to_string(runtime_lang)) == normalized + end) + + case runtime do + nil -> {:error, :runtime_not_found} + rt -> {:ok, rt} + end + end + + defp find_runtime(_runtimes, _language), do: {:error, :runtime_not_found} + + defp execute_code(runtime, attrs) do + request = %{ + "language" => Map.get(runtime, "language") || Map.get(runtime, :language), + "version" => Map.get(runtime, "version") || Map.get(runtime, :version), + "files" => [%{"content" => attrs.code}], + "stdin" => attrs.test_input + } + + case Piston.execute(request) do + {:ok, result} -> + if is_successful_execution?(result) do + {:ok, result} + else + {:error, {:piston_error, result}} + end + + {:error, _reason} -> + {:error, :service_unavailable} + end + end + + defp is_successful_execution?(result) when is_map(result) do + # Piston returns successful executions with run.code == 0 or similar structure + # We consider it successful if we got a response (errors are in the response itself) + Map.has_key?(result, "run") || Map.has_key?(result, :run) + end + + defp is_successful_execution?(_result), do: false + + defp calculate_result(execution_result, expected_output) do + run = execution_result["run"] || execution_result[:run] || %{} + output = run["output"] || run["stdout"] || run[:output] || run[:stdout] || "" + exit_code = run["code"] || run[:code] || 0 + + passed = + if exit_code == 0 do + trimmed_output = String.trim_trailing(to_string(output)) + trimmed_expected = String.trim_trailing(to_string(expected_output)) + if trimmed_output == trimmed_expected, do: 1, else: 0 + else + 0 + end + + failed = 1 - passed + success_rate = if passed == 1, do: 1.0, else: 0.0 + + %{ + result: if(passed == 1, do: "SUCCESS", else: "ERROR"), + successRate: success_rate, + passed: passed, + failed: failed, + total: 1 + } + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex new file mode 100644 index 00000000..6086c4a8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/fallback_controller.ex @@ -0,0 +1,45 @@ +defmodule CodincodApiWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid Plug responses. + """ + + use CodincodApiWeb, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(%{message: "Resource not found"}) + end + + def call(conn, {:error, :unauthorized}) do + conn + |> put_status(:unauthorized) + |> json(%{message: "Unauthorized"}) + end + + def call(conn, {:error, :forbidden}) do + conn + |> put_status(:forbidden) + |> json(%{message: "Forbidden"}) + end + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + + def call(conn, {:error, reason}) do + conn + |> put_status(:internal_server_error) + |> json(%{message: "Internal server error", reason: inspect(reason)}) + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex new file mode 100644 index 00000000..e96f0b87 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/game_controller.ex @@ -0,0 +1,519 @@ +defmodule CodincodApiWeb.GameController do + @moduledoc """ + Handles game lobby creation, joining, and management for multiplayer coding challenges. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Games, Puzzles} + alias CodincodApi.Accounts.User + alias CodincodApi.Games.Game + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Games"]) + + operation(:list_waiting_rooms, + summary: "List all waiting game lobbies", + responses: %{ + 200 => {"Waiting rooms", "application/json", Schemas.Games.WaitingRoomsResponse} + } + ) + + def list_waiting_rooms(conn, _params) do + rooms = Games.list_waiting_rooms() + + conn + |> put_status(:ok) + |> json(%{ + rooms: Enum.map(rooms, &serialize_game/1), + count: length(rooms) + }) + end + + operation(:create, + summary: "Create a new game lobby", + request_body: {"Game creation payload", "application/json", Schemas.Games.CreateGameRequest}, + responses: %{ + 201 => {"Game created", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_create_params(params, user_id), + {:ok, _puzzle} <- Puzzles.fetch_puzzle(attrs.puzzle_id), + {:ok, game} <- Games.create_game(attrs) do + conn + |> put_status(:created) + |> json(serialize_game(game)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game creation payload"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", details: translate_errors(changeset)}) + end + end + + operation(:show, + summary: "Get game details", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Game details", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => game_id}) do + with {:ok, game_uuid} <- parse_uuid(game_id) do + game = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + conn + |> put_status(:ok) + |> json(serialize_game(game)) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:join, + summary: "Join a game lobby", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Joined game", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse}, + 409 => {"Game full or already started", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def join(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- validate_can_join(game, user_id), + {:ok, _game_player} <- Games.join_game(game, %{user_id: user_id}) do + # Reload game with updated players + updated_game = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + # Broadcast to game channel that player joined + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_joined", + serialize_game(updated_game) + ) + + conn + |> put_status(:ok) + |> json(serialize_game(updated_game)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + + {:error, :game_full} -> + conn + |> put_status(:conflict) + |> json(%{error: "Game is full"}) + + {:error, :game_started} -> + conn + |> put_status(:conflict) + |> json(%{error: "Game has already started"}) + + {:error, :already_joined} -> + conn + |> put_status(:conflict) + |> json(%{error: "Already in this game"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to join game", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:leave, + summary: "Leave a game lobby", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Left game", "application/json", Schemas.Games.LeaveGameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def leave(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid), + :ok <- Games.leave_game(game, user_id) do + # Broadcast to game channel that player left + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_left", + %{userId: user_id} + ) + + conn + |> put_status(:ok) + |> json(%{message: "Left game successfully"}) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:start, + summary: "Start a game (host only)", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Game started", "application/json", Schemas.Games.GameResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Not game host", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def start(conn, %{"id" => game_id}) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + game <- Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]), + :ok <- validate_is_host(game, user_id), + {:ok, _updated_game} <- + Games.transition_game(game, "in_progress", %{started_at: DateTime.utc_now()}) do + # Reload to get associations + game_with_assocs = Games.get_game!(game_uuid, preload: [:owner, :puzzle, players: :user]) + + # Broadcast game start + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "game_started", + serialize_game(game_with_assocs) + ) + + conn + |> put_status(:ok) + |> json(serialize_game(game_with_assocs)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game ID"}) + + {:error, :not_host} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Only the host can start the game"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to start game", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + operation(:submit_code, + summary: "Submit code for a game", + description: "Links an existing submission to a game, marking it as a player's game submission.", + parameters: [ + id: [ + in: :path, + description: "Game identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: {"Game submission", "application/json", Schemas.Games.GameSubmitCodeRequest}, + responses: %{ + 200 => {"Submission linked to game", "application/json", Schemas.Games.SubmitCodeResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Not a game participant", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Game or submission not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def submit_code(conn, %{"id" => game_id, "submissionId" => submission_id}) do + alias CodincodApi.Submissions + + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, game_uuid} <- parse_uuid(game_id), + {:ok, submission_uuid} <- parse_uuid(submission_id), + game <- Games.get_game!(game_uuid, preload: [:players]), + :ok <- validate_is_participant(game, user_id), + {:ok, submission} <- Submissions.get_submission(submission_uuid), + :ok <- validate_submission_owner(submission, user_id), + {:ok, updated_submission} <- + Submissions.link_to_game(submission, game_uuid) do + # Broadcast to game channel + CodincodApiWeb.Endpoint.broadcast( + "game:#{game_id}", + "player_submitted", + %{ + userId: user_id, + submissionId: submission_id, + gameId: game_id + } + ) + + conn + |> put_status(:ok) + |> json(%{ + message: "Submission linked to game", + submissionId: updated_submission.id, + gameId: game_id + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid game or submission ID"}) + + {:error, :not_participant} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You are not a participant in this game"}) + + {:error, :not_owner} -> + conn + |> put_status(:forbidden) + |> json(%{error: "You can only submit your own code"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Submission not found"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Failed to link submission", details: translate_errors(changeset)}) + end + rescue + Ecto.NoResultsError -> + conn + |> put_status(:not_found) + |> json(%{error: "Game not found"}) + end + + ## Private functions + + defp normalize_create_params(params, user_id) when is_map(params) do + with {:ok, puzzle_id} <- get_and_parse_uuid(params, "puzzleId") do + # Map frontend fields to actual schema fields + mode = Map.get(params, "gameMode", "FASTEST") + visibility = Map.get(params, "visibility", "public") + max_duration = Map.get(params, "timeLimit", 600) + + {:ok, + %{ + owner_id: user_id, + puzzle_id: puzzle_id, + mode: mode, + visibility: visibility, + max_duration_seconds: max_duration, + status: "waiting" + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp validate_can_join(%Game{status: status}, _user_id) when status != "waiting" do + {:error, :game_started} + end + + defp validate_can_join(%Game{players: players}, user_id) do + # Note: max_players is not in schema, so we just check if already joined + # You may need to add max_players field to games table if needed + cond do + Enum.any?(players, fn p -> p.user_id == user_id end) -> + {:error, :already_joined} + + true -> + :ok + end + end + + defp validate_is_host(%Game{owner_id: owner_id}, user_id) do + if owner_id == user_id do + :ok + else + {:error, :not_host} + end + end + + defp validate_is_participant(%Game{players: players}, user_id) do + if Enum.any?(players, fn p -> p.user_id == user_id end) do + :ok + else + {:error, :not_participant} + end + end + + defp validate_submission_owner(%{user_id: submission_user_id}, user_id) do + if submission_user_id == user_id do + :ok + else + {:error, :not_owner} + end + end + + defp serialize_game(%Game{} = game) do + %{ + id: game.id, + status: game.status, + mode: game.mode, + visibility: game.visibility, + maxDurationSeconds: game.max_duration_seconds, + rated: game.rated, + owner: + game.owner && + %{ + id: game.owner.id, + username: game.owner.username + }, + puzzle: + game.puzzle && + %{ + id: game.puzzle.id, + title: game.puzzle.title, + difficulty: game.puzzle.difficulty + }, + players: + Enum.map(game.players || [], fn player -> + %{ + id: player.user.id, + username: player.user.username, + role: player.role, + joinedAt: player.joined_at + } + end), + createdAt: game.inserted_at, + startedAt: game.started_at, + endedAt: game.ended_at + } + end + + defp get_and_parse_uuid(params, key) do + case Map.get(params, key) do + nil -> {:error, :missing_field} + value -> parse_uuid(value) + end + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex new file mode 100644 index 00000000..c29db530 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/health_controller.ex @@ -0,0 +1,33 @@ +defmodule CodincodApiWeb.HealthController do + @moduledoc """ + Health check endpoint for monitoring service availability. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + tags(["Health"]) + + operation(:show, + summary: "Health check", + description: "Returns service health status", + responses: %{ + 200 => { + "Health status", + "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + status: %OpenApiSpex.Schema{type: :string, example: "OK"} + } + } + } + } + ) + + def show(conn, _params) do + conn + |> put_status(:ok) + |> json(%{status: "OK"}) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex new file mode 100644 index 00000000..303ce246 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/leaderboard_controller.ex @@ -0,0 +1,221 @@ +defmodule CodincodApiWeb.LeaderboardController do + @moduledoc """ + Handles leaderboard and ranking queries for users across different game modes and puzzles. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Metrics, Puzzles, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Metrics.UserMetric + alias CodincodApi.Submissions.Submission + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Leaderboard"]) + + operation(:global, + summary: "Get global leaderboard rankings", + parameters: [ + game_mode: [ + in: :query, + description: "Game mode filter", + schema: %OpenApiSpex.Schema{type: :string, enum: ["standard", "timed", "ranked"]}, + required: false + ], + limit: [ + in: :query, + description: "Number of entries to return (1-100)", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + required: false + ], + offset: [ + in: :query, + description: "Pagination offset", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + required: false + ] + ], + responses: %{ + 200 => + {"Leaderboard rankings", "application/json", + Schemas.Leaderboard.GlobalLeaderboardResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def global(conn, params) do + game_mode = Map.get(params, "game_mode", "standard") + limit = parse_int(params["limit"], 50, 1, 100) + offset = parse_int(params["offset"], 0, 0, 10_000) + + # Try to use cached snapshot if available + snapshot = Metrics.latest_snapshot(game_mode) + + rankings = + if snapshot && fresh_snapshot?(snapshot) do + # Use cached snapshot + snapshot.rankings + |> Enum.slice(offset, limit) + else + # Compute live rankings + compute_global_rankings(game_mode, limit, offset) + end + + conn + |> put_status(:ok) + |> json(%{ + gameMode: game_mode, + rankings: rankings, + limit: limit, + offset: offset, + cachedAt: snapshot && snapshot.captured_at + }) + end + + operation(:puzzle, + summary: "Get puzzle-specific leaderboard", + parameters: [ + puzzle_id: [ + in: :path, + description: "Puzzle identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ], + limit: [ + in: :query, + description: "Number of entries to return (1-100)", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + required: false + ] + ], + responses: %{ + 200 => + {"Puzzle leaderboard", "application/json", Schemas.Leaderboard.PuzzleLeaderboardResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def puzzle(conn, %{"puzzle_id" => puzzle_id} = params) do + limit = parse_int(params["limit"], 50, 1, 100) + + with {:ok, puzzle_uuid} <- parse_uuid(puzzle_id), + {:ok, _puzzle} <- Puzzles.fetch_puzzle(puzzle_uuid) do + rankings = compute_puzzle_rankings(puzzle_uuid, limit) + + conn + |> put_status(:ok) + |> json(%{ + puzzleId: puzzle_id, + rankings: rankings, + limit: limit + }) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid puzzle ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + end + end + + ## Private functions + + defp fresh_snapshot?(snapshot) do + # Consider snapshot fresh if less than 5 minutes old + DateTime.diff(DateTime.utc_now(), snapshot.captured_at, :second) < 300 + end + + defp compute_global_rankings(game_mode, limit, offset) do + UserMetric + |> where([m], m.game_mode == ^game_mode) + |> order_by([m], desc: m.rating, desc: m.puzzles_solved) + |> limit(^limit) + |> offset(^offset) + |> join(:inner, [m], u in User, on: m.user_id == u.id) + |> select([m, u], %{ + rank: over(row_number(), order_by: [desc: m.rating, desc: m.puzzles_solved]), + userId: u.id, + username: u.username, + rating: m.rating, + puzzlesSolved: m.puzzles_solved, + totalSubmissions: m.total_submissions + }) + |> Repo.all() + |> Enum.with_index(offset + 1) + |> Enum.map(fn {entry, idx} -> Map.put(entry, :rank, idx) end) + end + + defp compute_puzzle_rankings(puzzle_id, limit) do + # Get best submission per user for this puzzle + subquery = + from s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + group_by: s.user_id, + select: %{ + user_id: s.user_id, + best_time: + min( + fragment( + "CAST(? ->> 'executionTime' AS INTEGER)", + s.result + ) + ), + best_memory: + min( + fragment( + "CAST(? ->> 'memoryUsed' AS INTEGER)", + s.result + ) + ), + submitted_at: max(s.inserted_at) + } + + from(sq in subquery(subquery), + join: u in User, + on: sq.user_id == u.id, + order_by: [asc: sq.best_time, asc: sq.best_memory], + limit: ^limit, + select: %{ + userId: u.id, + username: u.username, + executionTime: sq.best_time, + memoryUsed: sq.best_memory, + submittedAt: sq.submitted_at + } + ) + |> Repo.all() + |> Enum.with_index(1) + |> Enum.map(fn {entry, idx} -> Map.put(entry, :rank, idx) end) + end + + defp parse_int(nil, default, _min, _max), do: default + + defp parse_int(value, default, min, max) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int >= min and int <= max -> int + _ -> default + end + end + + defp parse_int(value, _default, min, max) + when is_integer(value) and value >= min and value <= max, + do: value + + defp parse_int(_value, default, _min, _max), do: default + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex new file mode 100644 index 00000000..ffa92e28 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/metrics_controller.ex @@ -0,0 +1,324 @@ +defmodule CodincodApiWeb.MetricsController do + @moduledoc """ + Provides platform-wide metrics, user statistics, and puzzle analytics. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Ecto.Query + alias CodincodApi.{Accounts, Puzzles, Repo} + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Metrics"]) + + operation(:platform, + summary: "Get platform-wide statistics", + responses: %{ + 200 => {"Platform metrics", "application/json", Schemas.Metrics.PlatformMetricsResponse} + } + ) + + def platform(conn, _params) do + metrics = %{ + totalUsers: Repo.aggregate(User, :count), + totalPuzzles: Repo.aggregate(from(p in Puzzle, where: p.is_published == true), :count), + totalSubmissions: Repo.aggregate(Submission, :count), + acceptedSubmissions: + Repo.aggregate(from(s in Submission, where: s.status == "accepted"), :count), + activeUsers: count_active_users(7), + # Active in last 7 days + popularPuzzles: get_popular_puzzles(5) + } + + conn + |> put_status(:ok) + |> json(metrics) + end + + operation(:user_stats, + summary: "Get detailed statistics for a user", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"User statistics", "application/json", Schemas.Metrics.UserStatsResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def user_stats(conn, %{"user_id" => user_id}) do + with {:ok, user_uuid} <- parse_uuid(user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid) do + stats = compute_user_stats(user_uuid) + + conn + |> put_status(:ok) + |> json( + Map.merge(stats, %{ + userId: user.id, + username: user.username + }) + ) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + end + end + + operation(:puzzle_stats, + summary: "Get detailed statistics for a puzzle", + parameters: [ + puzzle_id: [ + in: :path, + description: "Puzzle identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"Puzzle statistics", "application/json", Schemas.Metrics.PuzzleStatsResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def puzzle_stats(conn, %{"puzzle_id" => puzzle_id}) do + with {:ok, puzzle_uuid} <- parse_uuid(puzzle_id), + {:ok, puzzle} <- Puzzles.fetch_puzzle(puzzle_uuid) do + stats = compute_puzzle_stats(puzzle_uuid) + + conn + |> put_status(:ok) + |> json( + Map.merge(stats, %{ + puzzleId: puzzle.id, + title: puzzle.title + }) + ) + else + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid puzzle ID format"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + end + end + + ## Private functions + + defp count_active_users(days) do + cutoff = DateTime.utc_now() |> DateTime.add(-days * 24 * 60 * 60, :second) + + Submission + |> where([s], s.inserted_at >= ^cutoff) + |> select([s], s.user_id) + |> distinct(true) + |> Repo.aggregate(:count) + end + + defp get_popular_puzzles(limit) do + # Get puzzles with most submissions in last 30 days + cutoff = DateTime.utc_now() |> DateTime.add(-30 * 24 * 60 * 60, :second) + + from(s in Submission, + where: s.inserted_at >= ^cutoff, + group_by: s.puzzle_id, + join: p in Puzzle, + on: s.puzzle_id == p.id, + select: %{ + puzzleId: p.id, + title: p.title, + difficulty: p.difficulty, + submissionCount: count(s.id) + }, + order_by: [desc: count(s.id)], + limit: ^limit + ) + |> Repo.all() + end + + defp compute_user_stats(user_id) do + # Get submission stats + submission_stats = + from(s in Submission, + where: s.user_id == ^user_id, + select: %{ + total: count(s.id), + accepted: filter(count(s.id), s.status == "accepted"), + wrong_answer: filter(count(s.id), s.status == "wrong_answer"), + time_limit: filter(count(s.id), s.status == "time_limit_exceeded"), + runtime_error: filter(count(s.id), s.status == "runtime_error") + } + ) + |> Repo.one() + + # Get unique puzzles solved + puzzles_solved = + from(s in Submission, + where: s.user_id == ^user_id and s.status == "accepted", + select: s.puzzle_id, + distinct: true + ) + |> Repo.aggregate(:count) + + # Get difficulty breakdown + difficulty_breakdown = + from(s in Submission, + where: s.user_id == ^user_id and s.status == "accepted", + join: p in Puzzle, + on: s.puzzle_id == p.id, + group_by: p.difficulty, + select: {p.difficulty, count(s.id)}, + distinct: [s.puzzle_id, p.difficulty] + ) + |> Repo.all() + |> Enum.into(%{}) + + # Get language usage + language_usage = + from(s in Submission, + where: s.user_id == ^user_id, + join: pl in assoc(s, :programming_language), + group_by: pl.name, + select: %{ + language: pl.name, + count: count(s.id) + }, + order_by: [desc: count(s.id)], + limit: 10 + ) + |> Repo.all() + + # Get recent activity (last 30 days) + cutoff = DateTime.utc_now() |> DateTime.add(-30 * 24 * 60 * 60, :second) + + recent_submissions = + Submission + |> where([s], s.user_id == ^user_id and s.inserted_at >= ^cutoff) + |> Repo.aggregate(:count) + + %{ + totalSubmissions: submission_stats.total, + acceptedSubmissions: submission_stats.accepted, + wrongAnswerSubmissions: submission_stats.wrong_answer, + timeLimitExceeded: submission_stats.time_limit, + runtimeErrors: submission_stats.runtime_error, + puzzlesSolved: puzzles_solved, + acceptanceRate: + if(submission_stats.total > 0, + do: Float.round(submission_stats.accepted / submission_stats.total * 100, 2), + else: 0.0 + ), + difficultyBreakdown: %{ + easy: Map.get(difficulty_breakdown, "easy", 0), + medium: Map.get(difficulty_breakdown, "medium", 0), + hard: Map.get(difficulty_breakdown, "hard", 0), + expert: Map.get(difficulty_breakdown, "expert", 0) + }, + languageUsage: language_usage, + recentActivity: recent_submissions + } + end + + defp compute_puzzle_stats(puzzle_id) do + # Get submission stats + submission_stats = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id, + select: %{ + total: count(s.id), + accepted: filter(count(s.id), s.status == "accepted"), + wrong_answer: filter(count(s.id), s.status == "wrong_answer"), + time_limit: filter(count(s.id), s.status == "time_limit_exceeded"), + runtime_error: filter(count(s.id), s.status == "runtime_error") + } + ) + |> Repo.one() + + # Get unique solvers + unique_solvers = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + select: s.user_id, + distinct: true + ) + |> Repo.aggregate(:count) + + # Get average execution time for accepted submissions + avg_execution_time = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + select: + avg( + fragment( + "CAST(? ->> 'executionTime' AS INTEGER)", + s.result + ) + ) + ) + |> Repo.one() + + # Get language distribution + language_distribution = + from(s in Submission, + where: s.puzzle_id == ^puzzle_id and s.status == "accepted", + join: pl in assoc(s, :programming_language), + group_by: pl.name, + select: %{ + language: pl.name, + count: count(s.id) + }, + order_by: [desc: count(s.id)] + ) + |> Repo.all() + + %{ + totalSubmissions: submission_stats.total, + acceptedSubmissions: submission_stats.accepted, + uniqueSolvers: unique_solvers, + acceptanceRate: + if(submission_stats.total > 0, + do: Float.round(submission_stats.accepted / submission_stats.total * 100, 2), + else: 0.0 + ), + averageExecutionTime: avg_execution_time && Float.round(avg_execution_time, 2), + languageDistribution: language_distribution, + statusBreakdown: %{ + accepted: submission_stats.accepted, + wrongAnswer: submission_stats.wrong_answer, + timeLimitExceeded: submission_stats.time_limit, + runtimeError: submission_stats.runtime_error + } + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex new file mode 100644 index 00000000..3393771b --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/moderation_controller.ex @@ -0,0 +1,549 @@ +defmodule CodincodApiWeb.ModerationController do + @moduledoc """ + Handles content moderation, reporting, and admin review workflows. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Accounts, Moderation} + alias CodincodApi.Accounts.User + alias CodincodApi.Moderation.{ModerationReview, Report} + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Moderation"]) + + ## Reports + + operation(:create_report, + summary: "Create a new report for inappropriate content", + request_body: {"Report payload", "application/json", Schemas.Moderation.CreateReportRequest}, + responses: %{ + 201 => {"Report created", "application/json", Schemas.Moderation.ReportResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create_report(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_report_params(params, user_id), + {:ok, report} <- Moderation.create_report(attrs, preload: [:reported_by, :resolved_by]) do + conn + |> put_status(:created) + |> json(serialize_report(report)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid report payload"}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: "Validation failed", details: translate_errors(changeset)}) + end + end + + operation(:list_reports, + summary: "List reports (admin only)", + parameters: [ + status: [ + in: :query, + description: "Filter by status", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["pending", "reviewing", "resolved", "dismissed"] + }, + required: false + ], + problem_type: [ + in: :query, + description: "Filter by problem type", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["spam", "inappropriate", "copyright", "harassment", "other"] + }, + required: false + ] + ], + responses: %{ + 200 => {"Reports list", "application/json", Schemas.Moderation.ReportsListResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def list_reports(conn, params) do + with %User{} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(user) do + filters = build_report_filters(params) + reports = Moderation.list_reports(filters, preload: [:reported_by, :resolved_by]) + + conn + |> put_status(:ok) + |> json(%{ + reports: Enum.map(reports, &serialize_report/1), + count: length(reports) + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + end + end + + operation(:resolve_report, + summary: "Resolve a report (admin only)", + parameters: [ + id: [ + in: :path, + description: "Report identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: + {"Resolution payload", "application/json", Schemas.Moderation.ResolveReportRequest}, + responses: %{ + 200 => {"Report resolved", "application/json", Schemas.Moderation.ReportResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Report not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def resolve_report(conn, %{"id" => report_id} = params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(user), + {:ok, report_uuid} <- parse_uuid(report_id), + report <- Moderation.get_report!(report_uuid), + {:ok, attrs} <- normalize_resolution_params(params, user_id), + {:ok, updated_report} <- + Moderation.resolve_report(report, attrs, preload: [:reported_by, :resolved_by]) do + conn + |> put_status(:ok) + |> json(serialize_report(updated_report)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid report ID"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid resolution payload"}) + end + end + + ## Moderation Reviews + + operation(:list_reviews, + summary: "List pending moderation reviews (moderator only)", + parameters: [ + status: [ + in: :query, + description: "Filter by status", + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["pending", "approved", "rejected"] + }, + required: false + ] + ], + responses: %{ + 200 => {"Reviews list", "application/json", Schemas.Moderation.ReviewsListResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def list_reviews(conn, params) do + with %User{} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_moderator(user) do + filters = build_review_filters(params) + reviews = Moderation.list_reviews(filters, preload: [:puzzle, :reviewer]) + + conn + |> put_status(:ok) + |> json(%{ + reviews: Enum.map(reviews, &serialize_review/1), + count: length(reviews) + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Moderator access required"}) + end + end + + operation(:review_content, + summary: "Review and approve/reject content (moderator only)", + parameters: [ + id: [ + in: :path, + description: "Review identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: + {"Review decision", "application/json", Schemas.Moderation.ReviewDecisionRequest}, + responses: %{ + 200 => {"Review updated", "application/json", Schemas.Moderation.ReviewResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Review not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def review_content(conn, %{"id" => review_id} = params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_moderator(user), + {:ok, review_uuid} <- parse_uuid(review_id), + review <- Moderation.get_review!(review_uuid), + {:ok, attrs} <- normalize_review_decision_params(params, user_id), + {:ok, updated_review} <- + Moderation.update_review(review, attrs, preload: [:puzzle, :reviewer]) do + conn + |> put_status(:ok) + |> json(serialize_review(updated_review)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Moderator access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid review ID"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid decision payload"}) + end + end + + ## User Management (Admin) + + operation(:ban_user, + summary: "Ban a user (admin only)", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + request_body: {"Ban details", "application/json", Schemas.Moderation.BanUserRequest}, + responses: %{ + 200 => {"User banned", "application/json", Schemas.Moderation.BanResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def ban_user(conn, %{"user_id" => target_user_id} = params) do + with %User{} = admin <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(admin), + {:ok, user_uuid} <- parse_uuid(target_user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid), + {:ok, attrs} <- normalize_ban_params(params), + {:ok, updated_user} <- Accounts.ban_user(user, attrs) do + conn + |> put_status(:ok) + |> json(%{ + userId: updated_user.id, + banned: true, + bannedUntil: updated_user.banned_until, + reason: attrs[:ban_reason] + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + + {:error, :invalid_payload} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid ban payload"}) + end + end + + operation(:unban_user, + summary: "Unban a user (admin only)", + parameters: [ + user_id: [ + in: :path, + description: "User identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid}, + required: true + ] + ], + responses: %{ + 200 => {"User unbanned", "application/json", Schemas.Moderation.BanResponse}, + 400 => {"Bad request", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"User not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def unban_user(conn, %{"user_id" => target_user_id}) do + with %User{} = admin <- conn.assigns[:current_user] || {:error, :unauthorized}, + :ok <- ensure_admin(admin), + {:ok, user_uuid} <- parse_uuid(target_user_id), + {:ok, user} <- Accounts.fetch_user(user_uuid), + {:ok, updated_user} <- Accounts.unban_user(user) do + conn + |> put_status(:ok) + |> json(%{ + userId: updated_user.id, + banned: false, + bannedUntil: nil + }) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{error: "Not authenticated"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Admin access required"}) + + {:error, :invalid_uuid} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid user ID"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "User not found"}) + end + end + + ## Private functions + + defp ensure_admin(%User{role: role}) do + if role in ["admin", "moderator"] do + :ok + else + {:error, :forbidden} + end + end + + defp ensure_moderator(%User{role: role}) do + if role in ["admin", "moderator"] do + :ok + else + {:error, :forbidden} + end + end + + defp normalize_report_params(params, user_id) when is_map(params) do + with {:ok, _content_type} <- get_required_field(params, "contentType"), + {:ok, content_id} <- get_required_field(params, "contentId"), + {:ok, problem_type} <- get_required_field(params, "problemType") do + {:ok, + %{ + reported_by_id: user_id, + problem_type: problem_type, + problem_reference_id: content_id, + explanation: Map.get(params, "description"), + status: "pending" + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_resolution_params(params, admin_id) when is_map(params) do + with {:ok, status} <- get_required_field(params, "status") do + {:ok, + %{ + status: status, + resolved_by_id: admin_id, + resolution_notes: Map.get(params, "resolutionNotes"), + resolved_at: DateTime.utc_now() + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_review_decision_params(params, reviewer_id) when is_map(params) do + with {:ok, status} <- get_required_field(params, "status") do + {:ok, + %{ + status: status, + reviewer_id: reviewer_id, + notes: Map.get(params, "reviewerNotes"), + resolved_at: DateTime.utc_now() + }} + else + _ -> {:error, :invalid_payload} + end + end + + defp normalize_ban_params(params) when is_map(params) do + duration_days = Map.get(params, "durationDays") + + banned_until = + if duration_days && is_integer(duration_days) do + DateTime.utc_now() |> DateTime.add(duration_days * 24 * 60 * 60, :second) + else + Map.get(params, "bannedUntil") + end + + {:ok, + %{ + banned_until: banned_until, + ban_reason: Map.get(params, "reason") + }} + end + + defp build_report_filters(params) do + %{} + |> maybe_add_filter(params, "status", :status) + |> maybe_add_filter(params, "problemType", :problem_type) + end + + defp build_review_filters(params) do + %{} + |> maybe_add_filter(params, "status", :status) + end + + defp maybe_add_filter(filters, params, key, filter_key) do + case Map.get(params, key) do + nil -> filters + value -> Map.put(filters, filter_key, value) + end + end + + defp get_required_field(params, key) do + case Map.get(params, key) do + nil -> {:error, :missing_field} + value -> {:ok, value} + end + end + + defp serialize_report(%Report{} = report) do + %{ + id: report.id, + contentType: report.problem_type, + contentId: report.problem_reference_id, + problemType: report.problem_type, + description: report.explanation, + status: report.status, + reportedBy: + report.reported_by && + %{ + id: report.reported_by.id, + username: report.reported_by.username + }, + resolvedBy: + report.resolved_by && + %{ + id: report.resolved_by.id, + username: report.resolved_by.username + }, + resolutionNotes: report.resolution_notes, + createdAt: report.inserted_at, + resolvedAt: report.resolved_at + } + end + + defp serialize_review(%ModerationReview{} = review) do + %{ + id: review.id, + puzzleId: review.puzzle_id, + status: review.status, + reviewer: + review.reviewer && + %{ + id: review.reviewer.id, + username: review.reviewer.username + }, + reviewerNotes: review.notes, + createdAt: review.inserted_at, + reviewedAt: review.resolved_at + } + end + + defp parse_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, :invalid_uuid} + end + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex new file mode 100644 index 00000000..36ff49e8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/open_api_controller.ex @@ -0,0 +1,10 @@ +defmodule CodincodApiWeb.OpenApiController do + @moduledoc "Serves the OpenAPI specification." + + use CodincodApiWeb, :controller + + def show(conn, _params) do + spec = CodincodApiWeb.OpenAPI.spec() |> OpenApiSpex.OpenApi.to_map() + json(conn, spec) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex new file mode 100644 index 00000000..31058fff --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/password_reset_controller.ex @@ -0,0 +1,179 @@ +defmodule CodincodApiWeb.PasswordResetController do + @moduledoc """ + Handles password reset requests and token validation. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + tags(["Password Reset"]) + + operation(:request_reset, + summary: "Request password reset", + description: "Sends password reset email if user exists", + request_body: {"Reset request", "application/json", Schemas.PasswordReset.RequestPayload}, + responses: %{ + 200 => {"Reset email sent", "application/json", Schemas.PasswordReset.RequestResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def request_reset(conn, params) do + with {:ok, attrs} <- normalize_request_params(params), + base_url <- get_base_url(conn), + {:ok, _reset} <- Accounts.request_password_reset(attrs.email, base_url) do + # Always return success to avoid email enumeration attacks + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + else + {:error, :user_not_found} -> + # Return same success message to prevent user enumeration + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid request", errors: errors}) + + {:error, _reason} -> + # Log error internally but show generic success to user + conn + |> put_status(:ok) + |> json(%{ + message: "If an account exists with this email, a password reset link has been sent." + }) + end + end + + operation(:reset_password, + summary: "Reset password with token", + description: "Validates token and updates user password", + request_body: {"Reset payload", "application/json", Schemas.PasswordReset.ResetPayload}, + responses: %{ + 200 => {"Password reset", "application/json", Schemas.PasswordReset.ResetResponse}, + 400 => {"Invalid payload or token", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def reset_password(conn, params) do + with {:ok, attrs} <- normalize_reset_params(params), + {:ok, _user} <- Accounts.reset_password_with_token(attrs.token, attrs.password) do + conn + |> put_status(:ok) + |> json(%{message: "Password successfully reset"}) + else + {:error, :invalid_token} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid or already used reset token"}) + + {:error, :expired_token} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Reset token has expired"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid reset payload", errors: errors}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to reset password", errors: translate_errors(changeset)}) + end + end + + defp normalize_request_params(params) when is_map(params) do + case Map.get(params, "email") do + email when is_binary(email) and byte_size(email) > 0 -> + {:ok, %{email: String.downcase(String.trim(email))}} + + _ -> + {:error, :invalid_payload, [%{field: "email", message: "is required"}]} + end + end + + defp normalize_request_params(_params), do: {:error, :invalid_payload, []} + + defp normalize_reset_params(params) when is_map(params) do + {token, errors} = validate_required_string(Map.get(params, "token"), "token") + {password, errors} = validate_password(Map.get(params, "password"), "password", errors) + + if errors == [] do + {:ok, %{token: token, password: password}} + else + {:error, :invalid_payload, errors} + end + end + + defp normalize_reset_params(_params), do: {:error, :invalid_payload, []} + + defp validate_required_string(value, field, errors \\ []) + + defp validate_required_string(value, field, errors) when is_binary(value) do + trimmed = String.trim(value) + + if trimmed == "" do + {nil, [%{field: field, message: "cannot be empty"} | errors]} + else + {trimmed, errors} + end + end + + defp validate_required_string(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp validate_password(value, field, errors) when is_binary(value) do + trimmed = String.trim(value) + + cond do + trimmed == "" -> + {nil, [%{field: field, message: "cannot be empty"} | errors]} + + String.length(trimmed) < 8 -> + {nil, [%{field: field, message: "must be at least 8 characters"} | errors]} + + true -> + {trimmed, errors} + end + end + + defp validate_password(_value, field, errors), + do: {nil, [%{field: field, message: "is required"} | errors]} + + defp get_base_url(conn) do + scheme = if conn.scheme == :https, do: "https", else: "http" + host = conn.host + port = conn.port + + port_part = + if (scheme == "https" and port == 443) or (scheme == "http" and port == 80) do + "" + else + ":#{port}" + end + + "#{scheme}://#{host}#{port_part}" + end + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex new file mode 100644 index 00000000..bc491547 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/programming_language_controller.ex @@ -0,0 +1,57 @@ +defmodule CodincodApiWeb.ProgrammingLanguageController do + @moduledoc """ + Controller for programming language endpoints. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Languages + + action_fallback CodincodApiWeb.FallbackController + + @doc """ + List all available programming languages. + """ + operation(:index, + summary: "List all programming languages", + responses: %{ + 200 => { + "Programming languages list", + "application/json", + %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + language: %OpenApiSpex.Schema{type: :string}, + version: %OpenApiSpex.Schema{type: :string}, + isActive: %OpenApiSpex.Schema{type: :boolean}, + runtime: %OpenApiSpex.Schema{type: :string}, + aliases: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}} + } + } + } + } + } + ) + + def index(conn, _params) do + languages = Languages.list_languages() + + # Serialize languages + serialized_languages = Enum.map(languages, fn language -> + %{ + id: language.id, + language: language.language, + version: language.version, + isActive: language.is_active, + runtime: language.runtime, + aliases: language.aliases || [] + } + end) + + json(conn, serialized_languages) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex new file mode 100644 index 00000000..a683acf6 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_comment_controller.ex @@ -0,0 +1,183 @@ +defmodule CodincodApiWeb.PuzzleCommentController do + @moduledoc """ + Creates puzzle comments and replies (mirrors Fastify `/puzzle/:id/comment`). + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.{Comments, Puzzles} + alias CodincodApi.Comments.Comment + alias CodincodApi.Accounts.User + alias CodincodApiWeb.OpenAPI.Schemas + + action_fallback CodincodApiWeb.FallbackController + + @min_length 1 + @max_length 320 + + operation(:create, + summary: "Create a comment on a puzzle", + parameters: [ + id: [ + in: :path, + description: "Puzzle ID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Comment creation payload", "application/json", Schemas.Comment.CreateRequest}, + responses: %{ + 201 => {"Comment created successfully", "application/json", Schemas.Comment.CommentResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle or parent comment not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Cannot reply to deleted comment or invalid parent", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() + def create(conn, %{"id" => puzzle_id} = params) do + with %User{id: user_id} = current_user <- conn.assigns[:current_user], + {:ok, %{text: text, reply_on: reply_on}} <- validate_payload(conn.body_params, params), + _puzzle <- Puzzles.get_puzzle!(puzzle_id), + {:ok, parent_comment} <- load_parent_comment(reply_on, puzzle_id), + attrs <- build_comment_attrs(puzzle_id, user_id, text, parent_comment), + {:ok, %Comment{} = comment} <- Comments.create_comment(attrs, preload: [:author]) do + conn + |> put_status(:created) + |> json(%{ + id: comment.id, + body: comment.body, + commentType: comment.comment_type, + upvote: comment.upvote_count, + downvote: comment.downvote_count, + authorId: comment.author_id, + puzzleId: comment.puzzle_id, + parentCommentId: comment.parent_comment_id, + insertedAt: comment.inserted_at, + updatedAt: comment.updated_at, + author: serialize_author(comment.author || current_user) + }) + else + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid payload", errors: errors}) + + {:error, :parent_not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Parent comment not found"}) + + {:error, :parent_deleted} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Cannot reply to a deleted comment"}) + + {:error, :invalid_parent} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Parent comment does not belong to this puzzle"}) + + error -> + CodincodApiWeb.FallbackController.call(conn, error) + end + end + + defp validate_payload(body_params, path_params) do + params = + body_params + |> normalize_params() + |> Map.merge(normalize_params(path_params)) + + text = Map.get(params, "text") || Map.get(params, "body") + reply_on = Map.get(params, "replyOn") || Map.get(params, "reply_on") + + with :ok <- validate_text(text), + {:ok, reply_on_id} <- parse_optional_uuid(reply_on) do + {:ok, %{text: text, reply_on: reply_on_id}} + else + {:error, reason} -> {:error, :invalid_payload, reason} + end + end + + defp normalize_params(%{} = params), do: params + defp normalize_params(_), do: %{} + + defp validate_text(text) when is_binary(text) do + len = String.length(text) + + cond do + len < @min_length -> + {:error, %{field: "text", message: "must be at least #{@min_length} characters"}} + + len > @max_length -> + {:error, %{field: "text", message: "must be at most #{@max_length} characters"}} + + true -> + :ok + end + end + + defp validate_text(_), do: {:error, %{field: "text", message: "must be a string"}} + + defp parse_optional_uuid(nil), do: {:ok, nil} + defp parse_optional_uuid(""), do: {:ok, nil} + + defp parse_optional_uuid(value) when is_binary(value) do + case Ecto.UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, %{field: "replyOn", message: "must be a valid UUID"}} + end + end + + defp parse_optional_uuid(_), do: {:error, %{field: "replyOn", message: "must be a UUID string"}} + + defp load_parent_comment(nil, _puzzle_id), do: {:ok, nil} + + defp load_parent_comment(parent_comment_id, puzzle_id) do + case Comments.get_comment(parent_comment_id) do + nil -> + {:error, :parent_not_found} + + %Comment{deleted_at: deleted_at} when not is_nil(deleted_at) -> + {:error, :parent_deleted} + + %Comment{puzzle_id: parent_puzzle_id} = comment when parent_puzzle_id == puzzle_id -> + {:ok, comment} + + _comment -> + {:error, :invalid_parent} + end + end + + defp build_comment_attrs(puzzle_id, user_id, text, nil) do + %{ + puzzle_id: puzzle_id, + author_id: user_id, + body: text, + comment_type: "puzzle-comment" + } + end + + defp build_comment_attrs(_puzzle_id, user_id, text, %Comment{} = parent) do + %{ + puzzle_id: parent.puzzle_id, + submission_id: parent.submission_id, + author_id: user_id, + body: text, + comment_type: "comment-comment", + parent_comment_id: parent.id + } + end + + defp serialize_author(%User{} = user) do + %{ + id: user.id, + username: user.username, + role: user.role + } + end + + defp serialize_author(_), do: nil +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex new file mode 100644 index 00000000..0ea83b99 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/puzzle_controller.ex @@ -0,0 +1,692 @@ +defmodule CodincodApiWeb.PuzzleController do + @moduledoc """ + Puzzle endpoints mirroring Fastify puzzle routes for listing and creation. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles + alias CodincodApi.Puzzles.Puzzle + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.PuzzleSerializer + + action_fallback CodincodApiWeb.FallbackController + + @default_page 1 + @default_page_size 20 + @min_page 1 + @min_page_size 1 + @max_page_size 100 + + tags(["Puzzle"]) + + operation(:index, + summary: "List puzzles", + description: + "Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response.", + parameters: [ + page: [ + in: :query, + description: "Page number", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1} + ], + pageSize: [ + in: :query, + description: "Number of puzzles per page", + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20} + ] + ], + responses: %{ + 200 => {"Paginated puzzles", "application/json", Schemas.Puzzle.PaginatedListResponse}, + 400 => {"Invalid query", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def index(conn, params) do + case validate_pagination(params) do + {:ok, pagination} -> + %{ + items: items, + page: page, + page_size: page_size, + total_items: total_items, + total_pages: total_pages + } = + Puzzles.paginate_all(pagination) + + response = %{ + items: PuzzleSerializer.render_many(items), + page: page, + pageSize: page_size, + totalItems: total_items, + totalPages: total_pages + } + + json(conn, response) + + {:error, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid pagination parameters", errors: errors}) + end + end + + operation(:create, + summary: "Create puzzle", + request_body: {"Puzzle creation payload", "application/json", Schemas.Puzzle.PuzzleCreateRequest}, + responses: %{ + 201 => {"Puzzle created", "application/json", Schemas.Puzzle.PuzzleResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Unprocessable entity", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} <- conn.assigns[:current_user], + {:ok, attrs} <- normalize_create_params(params), + # Set defaults for required DB fields that are optional in API + attrs_with_defaults = Map.merge( + %{ + author_id: user_id, + visibility: "DRAFT", + difficulty: "BEGINNER" # Default difficulty for new puzzles + }, + attrs + ), + {:ok, %Puzzle{} = puzzle} <- Puzzles.create_puzzle(attrs_with_defaults) do + conn + |> put_status(:created) + |> json(PuzzleSerializer.render(puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid puzzle payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to create puzzle", errors: translate_errors(changeset)}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + operation(:show, + summary: "Get puzzle by ID", + description: "Returns a single puzzle by ID (public view, no solution details).", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Puzzle found", "application/json", Schemas.Puzzle.PuzzleResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => id}) do + case Puzzles.fetch_puzzle(id) do + {:ok, puzzle} -> + json(conn, PuzzleSerializer.render(puzzle)) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + end + end + + operation(:solution, + summary: "Get puzzle solution for editing", + description: "Returns puzzle with full solution details. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Puzzle solution", "application/json", Schemas.Puzzle.PuzzleResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def solution(conn, %{"id" => id}) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle_with_validators(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role) do + json(conn, PuzzleSerializer.render(puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to access this puzzle's solution"}) + end + end + + operation(:update, + summary: "Update puzzle", + description: "Updates an existing puzzle. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + request_body: {"Puzzle update payload", "application/json", Schemas.Puzzle.PuzzleCreateRequest}, + responses: %{ + 200 => {"Puzzle updated", "application/json", Schemas.Puzzle.PuzzleResponse}, + 400 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Unprocessable entity", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def update(conn, %{"id" => id} = params) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role), + {:ok, attrs} <- normalize_update_params(params), + {:ok, %Puzzle{} = updated_puzzle} <- Puzzles.update_puzzle(puzzle, attrs) do + json(conn, PuzzleSerializer.render(updated_puzzle)) + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to update this puzzle"}) + + {:error, :invalid_payload, details} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid puzzle payload", errors: details}) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Unable to update puzzle", errors: translate_errors(changeset)}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + operation(:delete, + summary: "Delete puzzle", + description: "Deletes a puzzle. Only available to puzzle author or admins.", + parameters: [ + id: [ + in: :path, + description: "Puzzle UUID", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 204 => {"Puzzle deleted", nil, nil}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def delete(conn, %{"id" => id}) do + with %User{id: user_id, role: role} <- conn.assigns[:current_user], + {:ok, puzzle} <- Puzzles.fetch_puzzle(id), + :ok <- authorize_puzzle_access(puzzle, user_id, role), + {:ok, _puzzle} <- Puzzles.delete_puzzle(puzzle) do + send_resp(conn, :no_content, "") + else + nil -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{message: "Puzzle not found"}) + + {:error, :forbidden} -> + conn + |> put_status(:forbidden) + |> json(%{message: "You don't have permission to delete this puzzle"}) + + {:error, reason} -> + CodincodApiWeb.FallbackController.call(conn, {:error, reason}) + end + end + + defp validate_pagination(params) do + {page, page_errors} = + coerce_pagination_param(Map.get(params, "page"), "page", @default_page, min: @min_page) + + {page_size, size_errors} = + coerce_pagination_param( + Map.get(params, "pageSize"), + "pageSize", + @default_page_size, + min: @min_page_size, + max: @max_page_size + ) + + errors = page_errors ++ size_errors + + if errors == [] do + {:ok, %{page: page, page_size: page_size}} + else + {:error, errors} + end + end + + defp coerce_pagination_param(nil, _field, default, _opts), do: {default, []} + + defp coerce_pagination_param(value, field, default, opts) when is_binary(value) do + value + |> String.trim() + |> case do + "" -> + {default, []} + + trimmed -> + case Integer.parse(trimmed) do + {int, ""} -> coerce_pagination_param(int, field, default, opts) + _ -> {default, [%{field: field, message: "must be an integer"}]} + end + end + end + + defp coerce_pagination_param(value, field, default, opts) when is_integer(value) do + min = Keyword.get(opts, :min) + max = Keyword.get(opts, :max) + + cond do + min && value < min -> + {default, [%{field: field, message: "must be >= #{min}"}]} + + max && value > max -> + {default, [%{field: field, message: "must be <= #{max}"}]} + + true -> + {value, []} + end + end + + defp coerce_pagination_param(_value, field, default, _opts), + do: {default, [%{field: field, message: "must be an integer"}]} + + @allowed_difficulties %{ + "easy" => "BEGINNER", + "beginner" => "BEGINNER", + "medium" => "INTERMEDIATE", + "intermediate" => "INTERMEDIATE", + "hard" => "ADVANCED", + "advanced" => "ADVANCED", + "expert" => "EXPERT" + } + + defp normalize_create_params(params) when is_map(params) do + errors = [] + + # Title is the only required field for initial puzzle creation + {title, errors} = + case Map.get(params, "title") do + title when is_binary(title) -> + trimmed = String.trim(title) + + if String.length(trimmed) in 4..128 do + {trimmed, errors} + else + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + _ -> + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + # All other fields are optional during creation - can be filled in step-by-step via edit + statement = + case Map.get(params, "description") do + description when is_binary(description) -> + trimmed = String.trim(description) + if String.length(trimmed) >= 1, do: trimmed, else: nil + + _ -> + nil + end + + difficulty = + case Map.get(params, "difficulty") do + difficulty when is_binary(difficulty) -> + value = String.downcase(String.trim(difficulty)) + Map.get(@allowed_difficulties, value) + + _ -> + nil + end + + validators = + case Map.get(params, "validators") do + validators when is_list(validators) and validators != [] -> + parsed = + Enum.reduce(validators, {[], [], 0}, fn + %{"input" => input, "output" => output} = validator, {acc, errs, index} -> + cond do + not is_binary(input) or input == "" -> + {acc, + [%{field: "validators", index: index, message: "input is required"} | errs], + index + 1} + + not is_binary(output) or output == "" -> + {acc, + [%{field: "validators", index: index, message: "output is required"} | errs], + index + 1} + + true -> + validator_map = %{ + input: input, + output: output, + is_public: Map.get(validator, "isPublic", false) + } + + {[validator_map | acc], errs, index + 1} + end + + _validator, {acc, errs, index} -> + {acc, + [ + %{ + field: "validators", + index: index, + message: "must be objects with input/output" + } + | errs + ], index + 1} + end) + + case parsed do + {acc, [], _} -> Enum.reverse(acc) + {_acc, errs, _} -> {:error, errs} + end + + _ -> + [] + end + + # Check if validators parsing had errors + errors = + case validators do + {:error, validator_errors} -> errors ++ validator_errors + _ -> errors + end + + validators = if is_list(validators), do: validators, else: [] + + tags = + params + |> Map.get("tags") + |> normalize_tags() + + constraints = + params + |> Map.get("constraints") + |> normalize_optional_string() + + if errors == [] do + puzzle_attrs = + %{ + title: title, + statement: statement, + constraints: constraints, + difficulty: difficulty, + tags: tags, + validators: validators, + solution: %{} + } + |> Enum.reject(fn {_k, v} -> is_nil(v) or v == [] end) + |> Enum.into(%{}) + + {:ok, puzzle_attrs} + else + {:error, :invalid_payload, Enum.reverse(errors)} + end + end + + defp normalize_create_params(_), + do: {:error, :invalid_payload, [%{message: "Expected JSON body"}]} + + defp normalize_update_params(params) when is_map(params) do + # For updates, all fields are optional (only include what's being changed) + errors = [] + + # Title (optional for update, but if provided must be valid) + {title, errors} = + case Map.get(params, "title") do + nil -> + {nil, errors} + + title when is_binary(title) -> + trimmed = String.trim(title) + + if String.length(trimmed) in 4..128 do + {trimmed, errors} + else + {nil, [%{field: "title", message: "must be between 4 and 128 characters"} | errors]} + end + + _ -> + {nil, [%{field: "title", message: "must be a string"} | errors]} + end + + # Statement/description (optional) + statement = + case Map.get(params, "description") do + nil -> + nil + + description when is_binary(description) -> + trimmed = String.trim(description) + if String.length(trimmed) >= 1, do: trimmed, else: nil + + _ -> + nil + end + + # Difficulty (optional) + difficulty = + case Map.get(params, "difficulty") do + nil -> + nil + + difficulty when is_binary(difficulty) -> + value = String.downcase(String.trim(difficulty)) + Map.get(@allowed_difficulties, value) + + _ -> + nil + end + + # Visibility (optional) + visibility = + case Map.get(params, "visibility") do + nil -> + nil + + vis when is_binary(vis) -> + normalized = String.upcase(String.trim(vis)) + if normalized in ["DRAFT", "PUBLIC", "PRIVATE"], do: normalized, else: nil + + _ -> + nil + end + + # Validators (optional, but if provided must be valid) + validators = + case Map.get(params, "validators") do + nil -> + nil + + validators when is_list(validators) and validators != [] -> + parsed = + Enum.reduce(validators, {[], [], 0}, fn + %{"input" => input, "output" => output} = validator, {acc, errs, index} -> + cond do + not is_binary(input) or input == "" -> + {acc, + [%{field: "validators", index: index, message: "input is required"} | errs], + index + 1} + + not is_binary(output) or output == "" -> + {acc, + [%{field: "validators", index: index, message: "output is required"} | errs], + index + 1} + + true -> + validator_map = %{ + input: input, + output: output, + is_public: Map.get(validator, "isPublic", false) + } + + {[validator_map | acc], errs, index + 1} + end + + _validator, {acc, errs, index} -> + {acc, + [ + %{ + field: "validators", + index: index, + message: "must be objects with input/output" + } + | errs + ], index + 1} + end) + + case parsed do + {acc, [], _} -> Enum.reverse(acc) + {_acc, errs, _} -> {:error, errs} + end + + [] -> + [] + + _ -> + nil + end + + # Check if validators parsing had errors + errors = + case validators do + {:error, validator_errors} -> errors ++ validator_errors + _ -> errors + end + + validators = if is_list(validators), do: validators, else: nil + + # Tags (optional) + tags = + case Map.get(params, "tags") do + nil -> nil + tags_value -> normalize_tags(tags_value) + end + + # Constraints (optional) + constraints = + case Map.get(params, "constraints") do + nil -> nil + constraints_value -> normalize_optional_string(constraints_value) + end + + if errors == [] do + puzzle_attrs = + %{ + title: title, + statement: statement, + constraints: constraints, + difficulty: difficulty, + visibility: visibility, + tags: tags, + validators: validators + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + {:ok, puzzle_attrs} + else + {:error, :invalid_payload, Enum.reverse(errors)} + end + end + + defp normalize_update_params(_), + do: {:error, :invalid_payload, [%{message: "Expected JSON body"}]} + + # Authorization helper - checks if user can access/modify puzzle + defp authorize_puzzle_access(%Puzzle{author_id: author_id}, user_id, _role) + when author_id == user_id do + :ok + end + + defp authorize_puzzle_access(_puzzle, _user_id, "ADMIN"), do: :ok + defp authorize_puzzle_access(_puzzle, _user_id, _role), do: {:error, :forbidden} + + defp normalize_tags(nil), do: [] + + defp normalize_tags(tags) when is_list(tags) do + tags + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_tags(_), do: [] + + defp normalize_optional_string(nil), do: nil + defp normalize_optional_string(value) when is_binary(value), do: String.trim(value) + defp normalize_optional_string(_), do: nil + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex new file mode 100644 index 00000000..4fe8fbac --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/submission_controller.ex @@ -0,0 +1,312 @@ +defmodule CodincodApiWeb.SubmissionController do + @moduledoc """ + Handles submission creation and retrieval, mirroring the Fastify submission routes. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias Ecto.UUID + + alias CodincodApi.Accounts.User + alias CodincodApi.{Languages, Puzzles, Repo, Submissions} + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Submissions.{Evaluator, Submission} + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.{Helpers, SubmissionSerializer} + + action_fallback CodincodApiWeb.FallbackController + + tags(["Submission"]) + + operation(:create, + summary: "Submit code for evaluation", + request_body: + {"Submission payload", "application/json", Schemas.Submission.SubmitCodeRequest}, + responses: %{ + 201 => {"Submission created", "application/json", Schemas.Submission.SubmitCodeResponse}, + 400 => {"Invalid payload", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 403 => {"Forbidden", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Puzzle not found", "application/json", Schemas.Common.ErrorResponse}, + 422 => {"Validation error", "application/json", Schemas.Common.ErrorResponse}, + 503 => {"Execution unavailable", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def create(conn, params) do + with %User{id: user_id} = user <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, attrs} <- normalize_submit_params(params, user_id), + {:ok, puzzle} <- ensure_puzzle(attrs.puzzle_id), + {:ok, language} <- ensure_language(attrs.programming_language_id), + {:ok, evaluation} <- Evaluator.evaluate(attrs.code, puzzle, language), + {:ok, submission} <- + persist_submission(attrs, user, puzzle, language, evaluation.summary) do + response = build_submit_response(submission, evaluation.summary) + + conn + |> put_status(:created) + |> json(response) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, :user_mismatch} -> + conn + |> put_status(:forbidden) + |> json(%{error: "Authenticated user does not match submission payload"}) + + {:error, :invalid_payload, errors} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid submission payload", errors: errors}) + + {:error, {:puzzle, :not_found}} -> + conn + |> put_status(:not_found) + |> json(%{error: "Puzzle not found"}) + + {:error, {:puzzle, :no_validators}} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Failed to update the puzzle"}) + + {:error, {:language, :not_found}} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid programming language"}) + + {:error, {:invalid_field, field, message}} -> + conn + |> put_status(:bad_request) + |> json(%{ + message: "Invalid submission payload", + errors: [%{field: field, message: message}] + }) + + {:error, %Ecto.Changeset{} = changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{message: "Failed to create submission", errors: translate_errors(changeset)}) + + {:error, reason} -> + handle_execution_error(conn, reason) + end + end + + operation(:show, + summary: "Fetch submission by id", + parameters: [ + id: [ + in: :path, + description: "Submission identifier", + schema: %OpenApiSpex.Schema{type: :string, format: :uuid} + ] + ], + responses: %{ + 200 => {"Submission", "application/json", Schemas.Submission.SubmissionResponse}, + 400 => {"Invalid id", "application/json", Schemas.Common.ErrorResponse}, + 401 => {"Unauthorized", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + def show(conn, %{"id" => id}) do + with %User{} <- conn.assigns[:current_user] || {:error, :unauthorized}, + {:ok, submission_id} <- cast_uuid(id, "id"), + {:ok, submission} <- + Submissions.fetch_submission(submission_id, + preload: [:programming_language, :puzzle, :user] + ) do + conn + |> put_status(:ok) + |> json(SubmissionSerializer.render(submission)) + else + {:error, :unauthorized} -> + conn + |> put_status(:unauthorized) + |> json(%{message: "Not authenticated"}) + + {:error, {:invalid_field, field, message}} -> + conn + |> put_status(:bad_request) + |> json(%{ + message: "Invalid submission identifier", + errors: [%{field: field, message: message}] + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Submission not found"}) + end + end + + defp normalize_submit_params(params, current_user_id) when is_map(params) do + with {:ok, _user_id} <- ensure_user_matches(Map.get(params, "userId"), current_user_id) do + {code, errors} = validate_code(Map.get(params, "code")) + {puzzle_id, errors} = validate_uuid(Map.get(params, "puzzleId"), "puzzleId", errors) + + {language_id, errors} = + validate_uuid(Map.get(params, "programmingLanguageId"), "programmingLanguageId", errors) + + if errors == [] do + {:ok, + %{ + code: code, + puzzle_id: puzzle_id, + programming_language_id: language_id + }} + else + {:error, :invalid_payload, errors} + end + end + end + + defp normalize_submit_params(_params, _current_user_id), do: {:error, :invalid_payload, []} + + defp ensure_user_matches(nil, _current_user_id), + do: {:error, {:invalid_field, "userId", "is required"}} + + defp ensure_user_matches(user_id, current_user_id) when is_binary(user_id) do + with {:ok, uuid} <- cast_uuid(user_id, "userId") do + if uuid == current_user_id do + {:ok, uuid} + else + {:error, :user_mismatch} + end + end + end + + defp ensure_user_matches(_user_id, _current_user_id), + do: {:error, {:invalid_field, "userId", "must be a valid UUID"}} + + defp ensure_puzzle(puzzle_id) do + case Puzzles.fetch_puzzle_with_validators(puzzle_id) do + {:ok, %Puzzle{} = puzzle} -> + puzzle = Repo.preload(puzzle, :validators) + validators = Map.get(puzzle, :validators, []) + + if Enum.empty?(validators) do + {:error, {:puzzle, :no_validators}} + else + {:ok, puzzle} + end + + {:error, :not_found} -> + {:error, {:puzzle, :not_found}} + end + end + + defp ensure_language(language_id) do + case Languages.fetch_language(language_id) do + {:ok, %ProgrammingLanguage{} = language} -> {:ok, language} + {:error, :not_found} -> {:error, {:language, :not_found}} + end + end + + defp persist_submission( + attrs, + %User{id: user_id}, + %Puzzle{id: puzzle_id}, + %ProgrammingLanguage{id: language_id}, + summary + ) do + result_payload = build_result_payload(summary) + + attrs = %{ + code: attrs.code, + puzzle_id: puzzle_id, + user_id: user_id, + programming_language_id: language_id, + result: result_payload + } + + case Submissions.create_submission(attrs) do + {:ok, %Submission{} = submission} -> {:ok, submission} + {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset} + end + end + + defp validate_code(code) when is_binary(code) do + if String.trim(code) == "" do + {code, [%{field: "code", message: "must not be empty"}]} + else + {code, []} + end + end + + defp validate_code(_code), do: {nil, [%{field: "code", message: "must not be empty"}]} + + defp validate_uuid(value, field, errors) when is_binary(value) do + case UUID.cast(value) do + {:ok, uuid} -> {uuid, errors} + :error -> {nil, [%{field: field, message: "must be a valid UUID"} | errors]} + end + end + + defp validate_uuid(_value, field, errors), + do: {nil, [%{field: field, message: "must be a valid UUID"} | errors]} + + defp build_submit_response(%Submission{} = submission, summary) do + code = submission.code || "" + + %{ + submissionId: submission.id, + code: submission.code, + puzzleId: submission.puzzle_id, + programmingLanguageId: submission.programming_language_id, + userId: submission.user_id, + codeLength: String.length(code), + result: %{ + successRate: summary.success_rate, + passed: summary.passed, + failed: summary.failed, + total: summary.total + }, + createdAt: Helpers.format_datetime(submission.inserted_at) + } + end + + defp build_result_payload(summary) do + %{ + "result" => summary.result, + "successRate" => summary.success_rate, + "passed" => summary.passed, + "failed" => summary.failed, + "total" => summary.total + } + end + + defp handle_execution_error(conn, {:unexpected_status, status, _body}) do + conn + |> put_status(:bad_gateway) + |> json(%{error: "Execution service error", status: status}) + end + + defp handle_execution_error(conn, reason) do + conn + |> put_status(:service_unavailable) + |> json(%{error: "Execution service unavailable", reason: inspect(reason)}) + end + + defp cast_uuid(value, field) when is_binary(value) do + case UUID.cast(value) do + {:ok, uuid} -> {:ok, uuid} + :error -> {:error, {:invalid_field, field, "must be a valid UUID"}} + end + end + + defp cast_uuid(_value, field), do: {:error, {:invalid_field, field, "must be a valid UUID"}} + + defp translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex b/libs/backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex new file mode 100644 index 00000000..8c56e51c --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/controllers/user_controller.ex @@ -0,0 +1,230 @@ +defmodule CodincodApiWeb.UserController do + @moduledoc """ + User endpoints that expose profile data, availability checks and author-specific + resources. The responses are compatible with the legacy Fastify backend. + """ + + use CodincodApiWeb, :controller + use OpenApiSpex.ControllerSpecs + + require Logger + + alias CodincodApi.Accounts + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles + alias CodincodApi.Submissions + alias CodincodApiWeb.OpenAPI.Schemas + alias CodincodApiWeb.Serializers.{PuzzleSerializer, SubmissionSerializer, UserSerializer} + + action_fallback CodincodApiWeb.FallbackController + + tags(["User"]) + + operation(:show, + summary: "Get user by username", + parameters: [ + username: [ + in: :path, + description: "Username to look up", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"User", "application/json", Schemas.User.ShowResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + def show(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + json(conn, %{message: "User found", user: UserSerializer.render(user)}) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:activity, + summary: "Get user activity (puzzles and submissions)", + parameters: [ + username: [ + in: :path, + description: "Username to inspect", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"Activity", "application/json", Schemas.User.ActivityResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse}, + 500 => {"Server error", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec activity(Plug.Conn.t(), map()) :: Plug.Conn.t() + def activity(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + viewer_id = current_user_id(conn) + + try do + puzzles = + if viewer_id == user.id do + Puzzles.list_author_all(user.id) + else + Puzzles.list_author_public(user.id) + end + + submissions = Submissions.list_by_user(user.id) + + json(conn, %{ + message: "User activity found", + user: UserSerializer.render(user), + activity: %{ + puzzles: PuzzleSerializer.render_many(puzzles), + submissions: SubmissionSerializer.render_many(submissions) + } + }) + rescue + error -> + Logger.error("Failed to fetch user activity: #{inspect(error)}") + + conn + |> put_status(:internal_server_error) + |> json(%{message: "Internal Server Error"}) + end + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:puzzles, + summary: "List puzzles authored by a user", + parameters: [ + username: [ + in: :path, + description: "Username whose puzzles will be listed", + schema: %OpenApiSpex.Schema{type: :string} + ], + page: [ + in: :query, + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1} + ], + pageSize: [ + in: :query, + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20} + ] + ], + responses: %{ + 200 => {"Paginated puzzles", "application/json", Schemas.Puzzle.PaginatedListResponse}, + 400 => {"Invalid parameters", "application/json", Schemas.Common.ErrorResponse}, + 404 => {"Not found", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec puzzles(Plug.Conn.t(), map()) :: Plug.Conn.t() + def puzzles(conn, params = %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param), + %User{} = user <- Accounts.get_user_by_username(username) do + viewer_id = current_user_id(conn) + pagination = Puzzles.paginate_for_author(user.id, params, viewer_id: viewer_id) + + response = %{ + items: PuzzleSerializer.render_many(pagination.items), + page: pagination.page, + pageSize: pagination.page_size, + totalItems: pagination.total_items, + totalPages: pagination.total_pages + } + + json(conn, response) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + + nil -> + conn + |> put_status(:not_found) + |> json(%{message: "User not found"}) + end + end + + operation(:availability, + summary: "Check username availability", + parameters: [ + username: [ + in: :path, + description: "Desired username", + schema: %OpenApiSpex.Schema{type: :string} + ] + ], + responses: %{ + 200 => {"Availability", "application/json", Schemas.User.AvailabilityResponse}, + 400 => {"Invalid username", "application/json", Schemas.Common.ErrorResponse} + } + ) + + @spec availability(Plug.Conn.t(), map()) :: Plug.Conn.t() + def availability(conn, %{"username" => username_param}) do + with {:ok, username} <- normalize_username(username_param) do + json(conn, %{available: Accounts.username_available?(username)}) + else + {:error, :invalid_username, error} -> + conn + |> put_status(:bad_request) + |> json(%{message: "Invalid username", error: error}) + end + end + + defp normalize_username(username) when is_binary(username) do + trimmed = String.trim(username) + regex = User.username_regex() + min_len = User.username_min_length() + max_len = User.username_max_length() + + cond do + trimmed == "" -> + {:error, :invalid_username, %{field: "username", message: "is required"}} + + String.length(trimmed) < min_len or String.length(trimmed) > max_len -> + {:error, :invalid_username, + %{field: "username", message: "must be between #{min_len} and #{max_len} characters"}} + + not Regex.match?(regex, trimmed) -> + {:error, :invalid_username, %{field: "username", message: "contains invalid characters"}} + + true -> + {:ok, trimmed} + end + end + + defp normalize_username(_), + do: {:error, :invalid_username, %{field: "username", message: "must be a string"}} + + defp current_user_id(conn) do + case conn.assigns[:current_user] do + %User{id: id} -> id + _ -> nil + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/endpoint.ex b/libs/backend/codincod_api/lib/codincod_api_web/endpoint.ex new file mode 100644 index 00000000..56c6c83a --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/endpoint.ex @@ -0,0 +1,68 @@ +defmodule CodincodApiWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :codincod_api + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_codincod_api_key", + signing_salt: "lOUmvnf8", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # WebSocket endpoint for real-time features (games, notifications, etc.) + socket "/socket", CodincodApiWeb.UserSocket, + websocket: true, + longpoll: false + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :codincod_api, + gzip: not code_reloading?, + only: CodincodApiWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :codincod_api + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug CORSPlug, + origin: [ + ~r/^https?:\/\/localhost:5173$/, + ~r/^https?:\/\/localhost:3000$/, # Common React dev port + ~r/^https?:\/\/(www\.)?codincod\.com$/, + ], + credentials: true, # CRITICAL for cookies! + max_age: 86400, + headers: ["Authorization", "Content-Type", "Accept", "Origin"], + expose: ["Set-Cookie"] # Explicitly expose Set-Cookie header + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug CodincodApiWeb.Router +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/gettext.ex b/libs/backend/codincod_api/lib/codincod_api_web/gettext.ex new file mode 100644 index 00000000..072e3ff7 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule CodincodApiWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: CodincodApiWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :codincod_api +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/open_api.ex b/libs/backend/codincod_api/lib/codincod_api_web/open_api.ex new file mode 100644 index 00000000..eb84e804 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/open_api.ex @@ -0,0 +1,29 @@ +defmodule CodincodApiWeb.OpenAPI do + @moduledoc """ + OpenAPI specification entry point for the CodinCod Phoenix backend. + """ + + alias OpenApiSpex.{Components, Info, OpenApi, Paths, Server} + + @spec spec() :: OpenApi.t() + def spec do + %OpenApi{ + info: %Info{ + title: "CodinCod API", + version: "0.1.0", + description: "Phoenix implementation of the CodinCod backend" + }, + servers: [Server.from_endpoint(CodincodApiWeb.Endpoint)], + paths: Paths.from_router(CodincodApiWeb.Router), + components: components() + } + # Discover request/response schemas from path specs and resolve module references to $ref + |> OpenApiSpex.resolve_schema_modules() + end + + defp components do + %Components{ + schemas: CodincodApiWeb.OpenAPI.Schemas.registry() + } + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex new file mode 100644 index 00000000..4edb7b23 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas.ex @@ -0,0 +1,74 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas do + @moduledoc """ + Registry of OpenAPI schemas shared across the API specification. + """ + + def registry do + %{ + LoginRequest: CodincodApiWeb.OpenAPI.Schemas.Auth.LoginRequest.schema(), + RegisterRequest: CodincodApiWeb.OpenAPI.Schemas.Auth.RegisterRequest.schema(), + AuthMessageResponse: CodincodApiWeb.OpenAPI.Schemas.Auth.MessageResponse.schema(), + ErrorResponse: CodincodApiWeb.OpenAPI.Schemas.Common.ErrorResponse.schema(), + AccountStatusResponse: CodincodApiWeb.OpenAPI.Schemas.Account.StatusResponse.schema(), + AccountProfileUpdateRequest: + CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateRequest.schema(), + AccountProfileUpdateResponse: + CodincodApiWeb.OpenAPI.Schemas.Account.ProfileUpdateResponse.schema(), + AccountPreferences: CodincodApiWeb.OpenAPI.Schemas.Account.PreferencesPayload.schema(), + PuzzlePaginatedListResponse: + CodincodApiWeb.OpenAPI.Schemas.Puzzle.PaginatedListResponse.schema(), + PuzzleCreateRequest: CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleCreateRequest.schema(), + PuzzleResponse: CodincodApiWeb.OpenAPI.Schemas.Puzzle.PuzzleResponse.schema(), + UserSummary: CodincodApiWeb.OpenAPI.Schemas.User.Summary.schema(), + UserShowResponse: CodincodApiWeb.OpenAPI.Schemas.User.ShowResponse.schema(), + UserAvailabilityResponse: CodincodApiWeb.OpenAPI.Schemas.User.AvailabilityResponse.schema(), + UserActivityResponse: CodincodApiWeb.OpenAPI.Schemas.User.ActivityResponse.schema(), + SubmissionResponse: CodincodApiWeb.OpenAPI.Schemas.Submission.SubmissionResponse.schema(), + SubmissionSubmitRequest: + CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeRequest.schema(), + SubmissionSubmitResponse: + CodincodApiWeb.OpenAPI.Schemas.Submission.SubmitCodeResponse.schema(), + ExecuteRequest: CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteRequest.schema(), + ExecuteResponse: CodincodApiWeb.OpenAPI.Schemas.Execute.ExecuteResponse.schema(), + PasswordResetRequest: CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestPayload.schema(), + PasswordResetResponse: + CodincodApiWeb.OpenAPI.Schemas.PasswordReset.RequestResponse.schema(), + PasswordResetPayload: CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetPayload.schema(), + PasswordResetCompleteResponse: + CodincodApiWeb.OpenAPI.Schemas.PasswordReset.ResetResponse.schema(), + # Leaderboard schemas + GlobalLeaderboardResponse: + CodincodApiWeb.OpenAPI.Schemas.Leaderboard.GlobalLeaderboardResponse.schema(), + PuzzleLeaderboardResponse: + CodincodApiWeb.OpenAPI.Schemas.Leaderboard.PuzzleLeaderboardResponse.schema(), + UserRankResponse: CodincodApiWeb.OpenAPI.Schemas.Leaderboard.UserRankResponse.schema(), + # Metrics schemas + PlatformMetricsResponse: + CodincodApiWeb.OpenAPI.Schemas.Metrics.PlatformMetricsResponse.schema(), + UserStatsResponse: CodincodApiWeb.OpenAPI.Schemas.Metrics.UserStatsResponse.schema(), + PuzzleStatsResponse: CodincodApiWeb.OpenAPI.Schemas.Metrics.PuzzleStatsResponse.schema(), + # Moderation schemas + CreateReportRequest: CodincodApiWeb.OpenAPI.Schemas.Moderation.CreateReportRequest.schema(), + ReportResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportResponse.schema(), + ReportsListResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReportsListResponse.schema(), + ResolveReportRequest: + CodincodApiWeb.OpenAPI.Schemas.Moderation.ResolveReportRequest.schema(), + ReviewResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewResponse.schema(), + ReviewsListResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewsListResponse.schema(), + ReviewDecisionRequest: + CodincodApiWeb.OpenAPI.Schemas.Moderation.ReviewDecisionRequest.schema(), + BanUserRequest: CodincodApiWeb.OpenAPI.Schemas.Moderation.BanUserRequest.schema(), + BanResponse: CodincodApiWeb.OpenAPI.Schemas.Moderation.BanResponse.schema(), + # Games schemas + CreateGameRequest: CodincodApiWeb.OpenAPI.Schemas.Games.CreateGameRequest.schema(), + GameResponse: CodincodApiWeb.OpenAPI.Schemas.Games.GameResponse.schema(), + WaitingRoomsResponse: CodincodApiWeb.OpenAPI.Schemas.Games.WaitingRoomsResponse.schema(), + UserGamesResponse: CodincodApiWeb.OpenAPI.Schemas.Games.UserGamesResponse.schema(), + LeaveGameResponse: CodincodApiWeb.OpenAPI.Schemas.Games.LeaveGameResponse.schema(), + # Comment schemas + CommentCreateRequest: CodincodApiWeb.OpenAPI.Schemas.Comment.CreateRequest.schema(), + CommentResponse: CodincodApiWeb.OpenAPI.Schemas.Comment.CommentResponse.schema(), + CommentVoteRequest: CodincodApiWeb.OpenAPI.Schemas.Comment.VoteRequest.schema() + } + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex new file mode 100644 index 00000000..0f9177ab --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/account.ex @@ -0,0 +1,74 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Account do + @moduledoc """ + Account related schema definitions. + """ + + require OpenApiSpex + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule StatusResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "AccountStatusResponse", + type: :object, + required: [:isAuthenticated], + properties: %{ + isAuthenticated: %OpenApiSpex.Schema{type: :boolean}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule ProfileUpdateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ProfileUpdateRequest", + type: :object, + properties: %{ + bio: %OpenApiSpex.Schema{type: :string, maxLength: 500}, + location: %OpenApiSpex.Schema{type: :string, maxLength: 100}, + picture: %OpenApiSpex.Schema{type: :string, format: :uri}, + socials: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{type: :string, format: :uri}, + maxItems: 5 + } + } + }) + end + + defmodule PreferencesPayload do + @moduledoc false + OpenApiSpex.schema(%{ + title: "PreferencesPayload", + type: :object, + properties: %{ + preferredLanguage: %OpenApiSpex.Schema{type: :string, nullable: true}, + theme: %OpenApiSpex.Schema{ + type: :string, + enum: CodincodApi.Accounts.Preference.theme_options(), + nullable: true + }, + blockedUsers: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{type: :string, format: :uuid} + }, + editor: %OpenApiSpex.Schema{type: :object} + } + }) + end + + defmodule ProfileUpdateResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ProfileUpdateResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + profile: User.Profile.schema() + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex new file mode 100644 index 00000000..3a118432 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/auth.ex @@ -0,0 +1,46 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Auth do + @moduledoc """ + Auth related OpenAPI schemas. + """ + + require OpenApiSpex + + defmodule LoginRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "LoginRequest", + type: :object, + required: [:identifier, :password], + properties: %{ + identifier: %OpenApiSpex.Schema{type: :string, description: "Username or email"}, + password: %OpenApiSpex.Schema{type: :string, format: :password} + } + }) + end + + defmodule RegisterRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "RegisterRequest", + type: :object, + required: [:username, :email, :password], + properties: %{ + username: %OpenApiSpex.Schema{type: :string, minLength: 3, maxLength: 20}, + email: %OpenApiSpex.Schema{type: :string, format: :email}, + password: %OpenApiSpex.Schema{type: :string, format: :password, minLength: 14}, + passwordConfirmation: %OpenApiSpex.Schema{type: :string, format: :password} + } + }) + end + + defmodule MessageResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "MessageResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex new file mode 100644 index 00000000..ccdaebf5 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/comment.ex @@ -0,0 +1,66 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Comment do + @moduledoc """ + Comment schemas used across OpenAPI responses and requests. + """ + + require OpenApiSpex + + defmodule Author do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule CommentResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + body: %OpenApiSpex.Schema{type: :string}, + commentType: %OpenApiSpex.Schema{ + type: :string, + enum: ["puzzle-comment", "comment-comment", "submission-comment"] + }, + upvote: %OpenApiSpex.Schema{type: :integer, default: 0}, + downvote: %OpenApiSpex.Schema{type: :integer, default: 0}, + authorId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + parentCommentId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + insertedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + author: Author.schema() + }, + required: [:id, :body, :commentType, :authorId] + }) + end + + defmodule CreateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [:text], + properties: %{ + text: %OpenApiSpex.Schema{type: :string, minLength: 1, maxLength: 320}, + replyOn: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true} + } + }) + end + + defmodule VoteRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [:type], + properties: %{ + type: %OpenApiSpex.Schema{type: :string, enum: ["upvote", "downvote"]} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex new file mode 100644 index 00000000..9937ca78 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/common.ex @@ -0,0 +1,20 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Common do + @moduledoc """ + Shared schema utilities. + """ + + require OpenApiSpex + + defmodule ErrorResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ErrorResponse", + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + errors: %OpenApiSpex.Schema{type: :object}, + error: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex new file mode 100644 index 00000000..f6d2b296 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/execute.ex @@ -0,0 +1,54 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Execute do + @moduledoc """ + Execute API schemas for code execution without persistence. + """ + + require OpenApiSpex + + defmodule ExecuteRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["code", "language"], + properties: %{ + code: %OpenApiSpex.Schema{type: :string, minLength: 1}, + language: %OpenApiSpex.Schema{type: :string, minLength: 1}, + testInput: %OpenApiSpex.Schema{type: :string, default: ""}, + testOutput: %OpenApiSpex.Schema{type: :string, default: ""} + } + }) + end + + defmodule PuzzleResultInformation do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + result: %OpenApiSpex.Schema{type: :string, enum: ["SUCCESS", "ERROR"]}, + successRate: %OpenApiSpex.Schema{type: :number, minimum: 0, maximum: 1}, + passed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + failed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + total: %OpenApiSpex.Schema{type: :integer, minimum: 1} + } + }) + end + + defmodule ExecuteResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + run: %OpenApiSpex.Schema{ + type: :object, + additionalProperties: true + }, + compile: %OpenApiSpex.Schema{ + type: :object, + nullable: true, + additionalProperties: true + }, + puzzleResultInformation: PuzzleResultInformation.schema() + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex new file mode 100644 index 00000000..c18202d9 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/games.ex @@ -0,0 +1,156 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Games do + @moduledoc """ + OpenAPI schemas for game/multiplayer endpoints. + """ + + alias OpenApiSpex.{Schema, Reference} + + defmodule CreateGameRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreateGameRequest", + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + maxPlayers: %Schema{type: :integer, minimum: 2, maximum: 10, default: 2}, + gameMode: %Schema{ + type: :string, + enum: ["standard", "timed", "ranked"], + default: "standard" + }, + timeLimit: %Schema{type: :integer, nullable: true} + }, + required: [:puzzleId] + }) + end + + defmodule GameResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "GameResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + status: %Schema{type: :string}, + gameMode: %Schema{type: :string}, + maxPlayers: %Schema{type: :integer}, + timeLimit: %Schema{type: :integer, nullable: true}, + owner: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + puzzle: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + difficulty: %Schema{type: :string} + } + }, + players: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + role: %Schema{type: :string}, + joinedAt: %Schema{type: :string, format: :"date-time"} + } + } + }, + createdAt: %Schema{type: :string, format: :"date-time"}, + startedAt: %Schema{type: :string, format: :"date-time", nullable: true}, + finishedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule WaitingRoomsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "WaitingRoomsResponse", + type: :object, + properties: %{ + rooms: %Schema{ + type: :array, + items: GameResponse.schema() + }, + count: %Schema{type: :integer} + } + }) + end + + defmodule UserGamesResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "UserGamesResponse", + type: :object, + properties: %{ + games: %Schema{ + type: :array, + items: GameResponse.schema() + }, + count: %Schema{type: :integer} + } + }) + end + + defmodule LeaveGameResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "LeaveGameResponse", + type: :object, + properties: %{ + message: %Schema{type: :string} + } + }) + end + + defmodule GameSubmitCodeRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "GameSubmitCodeRequest", + description: "Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission)", + type: :object, + properties: %{ + submissionId: %Schema{ + type: :string, + format: :uuid, + description: "The ID of the submission to link to the game" + } + }, + required: [:submissionId] + }) + end + + defmodule SubmitCodeResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "SubmitCodeResponse", + type: :object, + properties: %{ + message: %Schema{type: :string}, + submissionId: %Schema{type: :string, format: :uuid}, + gameId: %Schema{type: :string, format: :uuid} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex new file mode 100644 index 00000000..a2c46a1e --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/leaderboard.ex @@ -0,0 +1,95 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Leaderboard do + @moduledoc """ + OpenAPI schemas for leaderboard endpoints. + """ + + alias OpenApiSpex.Schema + + defmodule GlobalLeaderboardResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + gameMode: %Schema{type: :string}, + rankings: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + rank: %Schema{type: :integer}, + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + rating: %Schema{type: :integer}, + puzzlesSolved: %Schema{type: :integer}, + totalSubmissions: %Schema{type: :integer}, + # Glicko rating system properties + glicko: %Schema{ + type: :object, + properties: %{ + rd: %Schema{type: :number, description: "Rating deviation"}, + vol: %Schema{type: :number, description: "Volatility"} + } + }, + # Game statistics + gamesPlayed: %Schema{type: :integer}, + gamesWon: %Schema{type: :integer}, + winRate: %Schema{type: :number, format: :float}, + bestScore: %Schema{type: :number}, + averageScore: %Schema{type: :number} + } + } + }, + limit: %Schema{type: :integer}, + offset: %Schema{type: :integer}, + totalPages: %Schema{type: :integer}, + totalEntries: %Schema{type: :integer}, + cachedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule PuzzleLeaderboardResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + rankings: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + rank: %Schema{type: :integer}, + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + executionTime: %Schema{type: :integer}, + memoryUsed: %Schema{type: :integer}, + submittedAt: %Schema{type: :string, format: :"date-time"} + } + } + }, + limit: %Schema{type: :integer} + } + }) + end + + defmodule UserRankResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + rank: %Schema{type: :integer, nullable: true}, + rating: %Schema{type: :integer, nullable: true}, + puzzlesSolved: %Schema{type: :integer, nullable: true}, + totalSubmissions: %Schema{type: :integer, nullable: true} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex new file mode 100644 index 00000000..1291e5d2 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/metrics.ex @@ -0,0 +1,112 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Metrics do + @moduledoc """ + OpenAPI schemas for metrics endpoints. + """ + + alias OpenApiSpex.Schema + + defmodule PlatformMetricsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + totalUsers: %Schema{type: :integer}, + totalPuzzles: %Schema{type: :integer}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + activeUsers: %Schema{type: :integer}, + popularPuzzles: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + difficulty: %Schema{type: :string}, + submissionCount: %Schema{type: :integer} + } + } + } + } + }) + end + + defmodule UserStatsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + wrongAnswerSubmissions: %Schema{type: :integer}, + timeLimitExceeded: %Schema{type: :integer}, + runtimeErrors: %Schema{type: :integer}, + puzzlesSolved: %Schema{type: :integer}, + acceptanceRate: %Schema{type: :number}, + difficultyBreakdown: %Schema{ + type: :object, + properties: %{ + easy: %Schema{type: :integer}, + medium: %Schema{type: :integer}, + hard: %Schema{type: :integer}, + expert: %Schema{type: :integer} + } + }, + languageUsage: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + language: %Schema{type: :string}, + count: %Schema{type: :integer} + } + } + }, + recentActivity: %Schema{type: :integer} + } + }) + end + + defmodule PuzzleStatsResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + puzzleId: %Schema{type: :string, format: :uuid}, + title: %Schema{type: :string}, + totalSubmissions: %Schema{type: :integer}, + acceptedSubmissions: %Schema{type: :integer}, + uniqueSolvers: %Schema{type: :integer}, + acceptanceRate: %Schema{type: :number}, + averageExecutionTime: %Schema{type: :number, nullable: true}, + languageDistribution: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + language: %Schema{type: :string}, + count: %Schema{type: :integer} + } + } + }, + statusBreakdown: %Schema{ + type: :object, + properties: %{ + accepted: %Schema{type: :integer}, + wrongAnswer: %Schema{type: :integer}, + timeLimitExceeded: %Schema{type: :integer}, + runtimeError: %Schema{type: :integer} + } + } + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex new file mode 100644 index 00000000..3dea107f --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/moderation.ex @@ -0,0 +1,205 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Moderation do + @moduledoc """ + OpenAPI schemas for moderation endpoints. + """ + + alias OpenApiSpex.{Schema, Reference} + + defmodule CreateReportRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CreateReportRequest", + type: :object, + properties: %{ + contentType: %Schema{type: :string, enum: ["puzzle", "comment", "submission", "user"]}, + contentId: %Schema{type: :string, format: :uuid}, + problemType: %Schema{ + type: :string, + enum: ["spam", "inappropriate", "copyright", "harassment", "other"] + }, + description: %Schema{type: :string, nullable: true} + }, + required: [:contentType, :contentId, :problemType] + }) + end + + defmodule ReportResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + contentType: %Schema{type: :string}, + contentId: %Schema{type: :string, format: :uuid}, + problemType: %Schema{type: :string}, + description: %Schema{type: :string, nullable: true}, + status: %Schema{type: :string}, + reportedBy: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + resolvedBy: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + resolutionNotes: %Schema{type: :string, nullable: true}, + createdAt: %Schema{type: :string, format: :"date-time"}, + resolvedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule ReportsListResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReportsListResponse", + type: :object, + properties: %{ + reports: %Schema{type: :array, items: %Reference{"$ref": "#/components/schemas/ReportResponse"}}, + count: %Schema{type: :integer} + } + }) + end + + defmodule ResolveReportRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ResolveReportRequest", + type: :object, + properties: %{ + status: %Schema{type: :string, enum: ["resolved", "dismissed"]}, + resolutionNotes: %Schema{type: :string, nullable: true} + }, + required: [:status] + }) + end + + defmodule ReviewResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + puzzleId: %Schema{type: :string, format: :uuid, nullable: true}, + status: %Schema{type: :string}, + # Fields for puzzle reviews + title: %Schema{type: :string, nullable: true}, + description: %Schema{type: :string, nullable: true}, + authorName: %Schema{type: :string, nullable: true}, + # Fields for report reviews + reportExplanation: %Schema{type: :string, nullable: true}, + reportedBy: %Schema{type: :string, nullable: true}, + reportedUserId: %Schema{type: :string, format: :uuid, nullable: true}, + reportedUserName: %Schema{type: :string, nullable: true}, + # Fields for game chat reports + gameId: %Schema{type: :string, format: :uuid, nullable: true}, + reportedMessageId: %Schema{type: :string, format: :uuid, nullable: true}, + contextMessages: %Schema{ + type: :array, + nullable: true, + items: %Schema{ + type: :object, + properties: %{ + _id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string}, + message: %Schema{type: :string}, + timestamp: %Schema{type: :string, format: :"date-time"} + } + } + }, + # Review metadata + reviewer: %Schema{ + type: :object, + nullable: true, + properties: %{ + id: %Schema{type: :string, format: :uuid}, + username: %Schema{type: :string} + } + }, + reviewerNotes: %Schema{type: :string, nullable: true}, + createdAt: %Schema{type: :string, format: :"date-time"}, + reviewedAt: %Schema{type: :string, format: :"date-time", nullable: true} + } + }) + end + + defmodule ReviewsListResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewsListResponse", + type: :object, + properties: %{ + reviews: %Schema{type: :array, items: %Reference{"$ref": "#/components/schemas/ReviewResponse"}}, + count: %Schema{type: :integer} + } + }) + end + + defmodule ReviewDecisionRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ReviewDecisionRequest", + type: :object, + properties: %{ + status: %Schema{type: :string, enum: ["approved", "rejected"]}, + reviewerNotes: %Schema{type: :string, nullable: true} + }, + required: [:status] + }) + end + + defmodule BanUserRequest do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BanUserRequest", + type: :object, + properties: %{ + durationDays: %Schema{type: :integer, nullable: true}, + bannedUntil: %Schema{type: :string, format: :"date-time", nullable: true}, + reason: %Schema{type: :string, nullable: true} + } + }) + end + + defmodule BanResponse do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BanResponse", + type: :object, + properties: %{ + userId: %Schema{type: :string, format: :uuid}, + banned: %Schema{type: :boolean}, + bannedUntil: %Schema{type: :string, format: :"date-time", nullable: true}, + reason: %Schema{type: :string, nullable: true} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex new file mode 100644 index 00000000..e233b19f --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/password_reset.ex @@ -0,0 +1,50 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.PasswordReset do + @moduledoc """ + Password reset API schemas. + """ + + require OpenApiSpex + + defmodule RequestPayload do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["email"], + properties: %{ + email: %OpenApiSpex.Schema{type: :string, format: :email} + } + }) + end + + defmodule RequestResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end + + defmodule ResetPayload do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["token", "password"], + properties: %{ + token: %OpenApiSpex.Schema{type: :string}, + password: %OpenApiSpex.Schema{type: :string, minLength: 8} + } + }) + end + + defmodule ResetResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex new file mode 100644 index 00000000..6d41d8d6 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/puzzle.ex @@ -0,0 +1,128 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Puzzle do + @moduledoc """ + Puzzle schemas used across OpenAPI responses and requests. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule Validator do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + input: %OpenApiSpex.Schema{type: :string, description: "Validator input payload"}, + output: %OpenApiSpex.Schema{type: :string, description: "Expected validator output"}, + isPublic: %OpenApiSpex.Schema{type: :boolean, default: false}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule Solution do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + code: %OpenApiSpex.Schema{type: :string, default: ""}, + programmingLanguage: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule Author do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + username: %OpenApiSpex.Schema{type: :string}, + profile: User.Profile.schema(), + role: %OpenApiSpex.Schema{type: :string}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time"} + } + }) + end + + defmodule PuzzleResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + title: %OpenApiSpex.Schema{type: :string}, + statement: %OpenApiSpex.Schema{type: :string, nullable: true}, + constraints: %OpenApiSpex.Schema{type: :string, nullable: true}, + author: Author.schema(), + validators: %OpenApiSpex.Schema{type: :array, items: Validator.schema()}, + difficulty: %OpenApiSpex.Schema{type: :string}, + visibility: %OpenApiSpex.Schema{type: :string}, + createdAt: %OpenApiSpex.Schema{type: :string, format: :"date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: :"date-time", nullable: true}, + solution: Solution.schema(), + puzzleMetrics: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyMetricsId: %OpenApiSpex.Schema{type: :string, nullable: true}, + tags: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + comments: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + moderationFeedback: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PuzzleCreateRequest do + @moduledoc false + OpenApiSpex.schema(%{ + title: "PuzzleCreateRequest", + type: :object, + required: [:title], + properties: %{ + title: %OpenApiSpex.Schema{type: :string, minLength: 4, maxLength: 128}, + description: %OpenApiSpex.Schema{type: :string, minLength: 1, nullable: true}, + difficulty: %OpenApiSpex.Schema{ + type: :string, + enum: ["easy", "medium", "hard", "beginner", "intermediate", "advanced", "expert"], + nullable: true + }, + validators: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + required: [:input, :output], + properties: %{ + input: %OpenApiSpex.Schema{type: :string}, + output: %OpenApiSpex.Schema{type: :string}, + isPublic: %OpenApiSpex.Schema{type: :boolean} + } + }, + nullable: true + }, + tags: %OpenApiSpex.Schema{ + type: :array, + nullable: true, + items: %OpenApiSpex.Schema{type: :string} + }, + constraints: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PaginatedListResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + page: %OpenApiSpex.Schema{type: :integer, minimum: 1, default: 1}, + pageSize: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100, default: 20}, + totalItems: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + totalPages: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + items: %OpenApiSpex.Schema{type: :array, items: PuzzleResponse.schema()} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex new file mode 100644 index 00000000..1bd6748a --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/submission.ex @@ -0,0 +1,115 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.Submission do + @moduledoc """ + Submission schemas used within OpenAPI responses. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.User + + defmodule ProgrammingLanguageSummary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + language: %OpenApiSpex.Schema{type: :string, nullable: true}, + version: %OpenApiSpex.Schema{type: :string, nullable: true}, + runtime: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule PuzzleSummary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + title: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule SubmissionResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + code: %OpenApiSpex.Schema{type: :string, nullable: true}, + result: %OpenApiSpex.Schema{type: :object, additionalProperties: true}, + score: %OpenApiSpex.Schema{type: :number, nullable: true}, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + puzzle: PuzzleSummary.schema(), + programmingLanguage: ProgrammingLanguageSummary.schema(), + user: User.Summary.schema(), + gameId: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyGameSubmissionId: %OpenApiSpex.Schema{type: :string, nullable: true} + } + }) + end + + defmodule SubmissionListResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :array, + items: SubmissionResponse.schema() + }) + end + + defmodule SubmitCodeRequest do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: ["puzzleId", "programmingLanguageId", "code", "userId"], + properties: %{ + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + programmingLanguageId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + code: %OpenApiSpex.Schema{type: :string, minLength: 1}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid} + } + }) + end + + defmodule SubmitCodeResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + required: [ + "submissionId", + "code", + "puzzleId", + "programmingLanguageId", + "userId", + "codeLength", + "result", + "createdAt" + ], + properties: %{ + submissionId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + code: %OpenApiSpex.Schema{type: :string}, + puzzleId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + programmingLanguageId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + userId: %OpenApiSpex.Schema{type: :string, format: :uuid}, + codeLength: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + result: %OpenApiSpex.Schema{ + type: :object, + required: ["successRate", "passed", "failed", "total"], + properties: %{ + successRate: %OpenApiSpex.Schema{type: :number, minimum: 0, maximum: 1}, + passed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + failed: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + total: %OpenApiSpex.Schema{type: :integer, minimum: 1} + } + }, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time"} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex new file mode 100644 index 00000000..fc138e7b --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/openapi/schemas/user.ex @@ -0,0 +1,97 @@ +defmodule CodincodApiWeb.OpenAPI.Schemas.User do + @moduledoc """ + User-related OpenAPI schemas shared across responses. + """ + + require OpenApiSpex + + alias CodincodApiWeb.OpenAPI.Schemas.{Puzzle, Submission} + + defmodule Profile do + @moduledoc false + OpenApiSpex.schema(%{ + title: "Profile", + type: :object, + properties: %{ + bio: %OpenApiSpex.Schema{type: :string, nullable: true}, + location: %OpenApiSpex.Schema{type: :string, nullable: true}, + picture: %OpenApiSpex.Schema{type: :string, nullable: true}, + socials: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}, nullable: true} + } + }) + end + + defmodule Summary do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + _id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + id: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + legacyId: %OpenApiSpex.Schema{type: :string, nullable: true}, + legacyUsername: %OpenApiSpex.Schema{type: :string, nullable: true}, + username: %OpenApiSpex.Schema{type: :string}, + profile: Profile.schema(), + role: %OpenApiSpex.Schema{type: :string, nullable: true}, + reportCount: %OpenApiSpex.Schema{type: :integer, nullable: true}, + banCount: %OpenApiSpex.Schema{type: :integer, nullable: true}, + currentBan: %OpenApiSpex.Schema{type: :string, format: :uuid, nullable: true}, + createdAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true}, + updatedAt: %OpenApiSpex.Schema{type: :string, format: "date-time", nullable: true} + } + }) + end + + defmodule ShowResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + user: Summary.schema() + } + }) + end + + defmodule AvailabilityResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + available: %OpenApiSpex.Schema{type: :boolean} + } + }) + end + + defmodule ActivityResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + user: Summary.schema(), + activity: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + puzzles: %OpenApiSpex.Schema{type: :array, items: Puzzle.PuzzleResponse.schema()}, + submissions: Submission.SubmissionListResponse.schema() + } + } + } + }) + end + + defmodule PaginatedPuzzlesResponse do + @moduledoc false + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + page: %OpenApiSpex.Schema{type: :integer, minimum: 1}, + pageSize: %OpenApiSpex.Schema{type: :integer, minimum: 1, maximum: 100}, + totalItems: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + totalPages: %OpenApiSpex.Schema{type: :integer, minimum: 0}, + items: %OpenApiSpex.Schema{type: :array, items: Puzzle.PuzzleResponse.schema()} + } + }) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex new file mode 100644 index 00000000..125c50c0 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/plugs/attach_token_from_cookie.ex @@ -0,0 +1,35 @@ +defmodule CodincodApiWeb.Plugs.AttachTokenFromCookie do + @moduledoc """ + Ensures Bearer tokens stored in cookies are exposed to Guardian pipelines. + + The legacy Fastify backend set an HTTP-only cookie named `token`. Since the + frontend continues to rely on that behaviour, this plug mirrors it by + promoting the cookie value to the `Authorization` header when a header is not + already present. + """ + + import Plug.Conn + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, _opts) do + conn = fetch_cookies(conn) + + case {get_req_header(conn, "authorization"), Map.get(conn.req_cookies, cookie_name())} do + {[], token} when is_binary(token) and byte_size(token) > 0 -> + put_req_header(conn, "authorization", "Bearer " <> token) + + _ -> + conn + end + end + + defp cookie_name do + Application.get_env(:codincod_api, :auth_cookie, []) + |> Keyword.get(:name, "token") + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex new file mode 100644 index 00000000..262581f7 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/plugs/current_user.ex @@ -0,0 +1,15 @@ +defmodule CodincodApiWeb.Plugs.CurrentUser do + @moduledoc """ + Plug to assign the current authenticated user to the connection. + """ + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + case Guardian.Plug.current_resource(conn) do + nil -> conn + user -> assign(conn, :current_user, user) + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex new file mode 100644 index 00000000..cce04ab3 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/plugs/open_api_spec.ex @@ -0,0 +1,19 @@ +defmodule CodincodApiWeb.Plugs.OpenApiSpec do + @moduledoc """ + Wrapper plug to attach the generated OpenAPI spec to the connection. + """ + + @behaviour Plug + + @impl Plug + def init(opts) do + opts + |> Keyword.put_new(:module, CodincodApiWeb.OpenAPI) + |> OpenApiSpex.Plug.PutApiSpec.init() + end + + @impl Plug + def call(conn, opts) do + OpenApiSpex.Plug.PutApiSpec.call(conn, opts) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex b/libs/backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex new file mode 100644 index 00000000..61bcc7d5 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/plugs/render_open_api.ex @@ -0,0 +1,18 @@ +defmodule CodincodApiWeb.Plugs.RenderOpenApi do + @moduledoc """ + Wrapper plug to render the OpenAPI specification. + """ + + @behaviour Plug + + @impl Plug + def init(opts) do + Keyword.put_new(opts, :json_library, Jason) + |> OpenApiSpex.Plug.RenderSpec.init() + end + + @impl Plug + def call(conn, opts) do + OpenApiSpex.Plug.RenderSpec.call(conn, opts) + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/router.ex b/libs/backend/codincod_api/lib/codincod_api_web/router.ex new file mode 100644 index 00000000..32f0b735 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/router.ex @@ -0,0 +1,143 @@ +defmodule CodincodApiWeb.Router do + use CodincodApiWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + pipeline :auth do + plug :debug_auth_request # ADD THIS + plug CodincodApiWeb.Plugs.AttachTokenFromCookie + plug CodincodApiWeb.Auth.Pipeline + plug CodincodApiWeb.Plugs.CurrentUser + end + + defp debug_auth_request(conn, _opts) do + require Logger + + conn = Plug.Conn.fetch_cookies(conn) + + Logger.info("=== AUTH REQUEST DEBUG ===") + Logger.info("Path: #{conn.request_path}") + Logger.info("All cookies: #{inspect(conn.req_cookies)}") + Logger.info("Token cookie exists?: #{Map.has_key?(conn.req_cookies, "token")}") + Logger.info("Token value: #{inspect(Map.get(conn.req_cookies, "token"))}") + Logger.info("Auth header: #{inspect(Plug.Conn.get_req_header(conn, "authorization"))}") + Logger.info("========================") + + conn +end + + pipeline :maybe_auth do + plug CodincodApiWeb.Plugs.AttachTokenFromCookie + + plug Guardian.Plug.VerifyHeader, + scheme: "Bearer", + module: CodincodApiWeb.Auth.Guardian, + allow_blank: true + + plug Guardian.Plug.LoadResource, + module: CodincodApiWeb.Auth.Guardian, + allow_blank: true + + plug CodincodApiWeb.Plugs.CurrentUser + end + + @api_versions ["/api"] + + for base_path <- @api_versions do + scope base_path, CodincodApiWeb do + pipe_through [:api] + + get "/openapi.json", OpenApiController, :show + get "/health", HealthController, :show + get "/puzzles", PuzzleController, :index + get "/programming-languages", ProgrammingLanguageController, :index + get "/user/:username", UserController, :show + get "/user/:username/activity", UserController, :activity + get "/user/:username/isAvailable", UserController, :availability + + post "/login", AuthController, :login + post "/register", AuthController, :register + post "/password-reset/request", PasswordResetController, :request_reset + post "/password-reset/reset", PasswordResetController, :reset_password + end + + scope base_path, CodincodApiWeb do + pipe_through [:api, :maybe_auth] + + get "/user/:username/puzzle", UserController, :puzzles + get "/comment/:id", CommentController, :show + get "/puzzle/:id", PuzzleController, :show + end + + scope base_path, CodincodApiWeb do + pipe_through [:api, :auth] + + post "/logout", AuthController, :logout + post "/refresh", AuthController, :refresh + + get "/account", AccountController, :show + patch "/account/profile", AccountController, :update_profile + get "/account/leaderboard", AccountController, :leaderboard_rank + get "/account/games", AccountController, :games + + get "/account/preferences", AccountPreferenceController, :show + put "/account/preferences", AccountPreferenceController, :replace + patch "/account/preferences", AccountPreferenceController, :patch + delete "/account/preferences", AccountPreferenceController, :delete + + delete "/comment/:id", CommentController, :delete + post "/comment/:id/vote", CommentController, :vote + + post "/puzzles", PuzzleController, :create + get "/puzzle/:id/solution", PuzzleController, :solution + patch "/puzzle/:id", PuzzleController, :update + delete "/puzzle/:id", PuzzleController, :delete + post "/puzzle/:id/comment", PuzzleCommentController, :create + post "/submission", SubmissionController, :create + get "/submission/:id", SubmissionController, :show + post "/execute", ExecuteController, :create + + get "/leaderboard/global", LeaderboardController, :global + get "/leaderboard/puzzle/:puzzle_id", LeaderboardController, :puzzle + + get "/metrics/platform", MetricsController, :platform + get "/metrics/user/:user_id", MetricsController, :user_stats + get "/metrics/puzzle/:puzzle_id", MetricsController, :puzzle_stats + + post "/moderation/report", ModerationController, :create_report + get "/moderation/reports", ModerationController, :list_reports + post "/moderation/report/:id/resolve", ModerationController, :resolve_report + get "/moderation/reviews", ModerationController, :list_reviews + post "/moderation/review/:id", ModerationController, :review_content + post "/moderation/user/:user_id/ban", ModerationController, :ban_user + post "/moderation/user/:user_id/unban", ModerationController, :unban_user + + get "/games/waiting", GameController, :list_waiting_rooms + post "/games", GameController, :create + get "/games/:id", GameController, :show + post "/games/:id/join", GameController, :join + post "/games/:id/leave", GameController, :leave + post "/games/:id/start", GameController, :start + post "/games/:id/submit", GameController, :submit_code + end + end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:codincod_api, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through [:fetch_session, :protect_from_forgery] + + live_dashboard "/dashboard", metrics: CodincodApiWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex new file mode 100644 index 00000000..3ff6458a --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/serializers/helpers.ex @@ -0,0 +1,16 @@ +defmodule CodincodApiWeb.Serializers.Helpers do + @moduledoc false + + @spec format_datetime(DateTime.t() | NaiveDateTime.t() | nil | term()) :: String.t() | nil + def format_datetime(nil), do: nil + def format_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + def format_datetime(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime) + def format_datetime(_), do: nil + + @spec coalesce([term()], term()) :: term() + def coalesce(values, default \\ nil) + + def coalesce([], default), do: default + def coalesce([nil | rest], default), do: coalesce(rest, default) + def coalesce([value | _], _default), do: value +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex new file mode 100644 index 00000000..6eaa281d --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/serializers/puzzle_serializer.ex @@ -0,0 +1,110 @@ +defmodule CodincodApiWeb.Serializers.PuzzleSerializer do + @moduledoc """ + Converts `CodincodApi.Puzzles.Puzzle` structs into JSON-ready maps aligned with the + legacy Fastify responses. + """ + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + alias CodincodApiWeb.Serializers.Helpers + + @spec render(Puzzle.t()) :: map() + def render(%Puzzle{} = puzzle) do + %{ + _id: puzzle.id, + id: puzzle.id, + legacyId: puzzle.legacy_id, + title: puzzle.title, + statement: puzzle.statement, + constraints: puzzle.constraints, + author: render_author(puzzle.author), + validators: render_validators(puzzle.validators || []), + difficulty: normalize_difficulty(puzzle.difficulty), + visibility: normalize_visibility(puzzle.visibility), + createdAt: Helpers.format_datetime(puzzle.inserted_at), + updatedAt: Helpers.format_datetime(puzzle.updated_at), + solution: normalize_solution(puzzle.solution), + puzzleMetrics: puzzle.metrics && puzzle.metrics.id, + legacyMetricsId: puzzle.legacy_metrics_id, + tags: puzzle.tags || [], + comments: puzzle.legacy_comments || [], + moderationFeedback: puzzle.moderation_feedback + } + end + + @spec render_many([Puzzle.t()]) :: [map()] + def render_many(puzzles) when is_list(puzzles) do + Enum.map(puzzles, &render/1) + end + + defp render_author(%User{} = user) do + %{ + _id: user.id, + id: user.id, + username: user.username, + profile: user.profile, + role: user.role, + createdAt: Helpers.format_datetime(user.inserted_at), + updatedAt: Helpers.format_datetime(user.updated_at) + } + end + + defp render_author(_), do: nil + + defp render_validators(validators) do + validators + |> Enum.map(fn + %PuzzleValidator{} = validator -> + %{ + input: validator.input, + output: validator.output, + isPublic: validator.is_public, + createdAt: Helpers.format_datetime(validator.inserted_at), + updatedAt: Helpers.format_datetime(validator.updated_at) + } + + _ -> + nil + end) + |> Enum.reject(&is_nil/1) + end + + defp normalize_solution(solution) when is_map(solution) do + %{ + code: + Helpers.coalesce( + [Map.get(solution, "code"), Map.get(solution, :code)], + "" + ), + programmingLanguage: + Helpers.coalesce([ + Map.get(solution, "programmingLanguage"), + Map.get(solution, :programmingLanguage), + Map.get(solution, "programming_language"), + Map.get(solution, :programming_language) + ]) + } + end + + defp normalize_solution(_), do: %{code: "", programmingLanguage: nil} + + defp normalize_difficulty(nil), do: nil + + defp normalize_difficulty(difficulty) when is_binary(difficulty) do + difficulty + |> String.trim() + |> String.downcase() + end + + defp normalize_difficulty(_), do: nil + + defp normalize_visibility(nil), do: nil + + defp normalize_visibility(visibility) when is_binary(visibility) do + visibility + |> String.trim() + |> String.downcase() + end + + defp normalize_visibility(_), do: nil +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex new file mode 100644 index 00000000..fc59de38 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/serializers/submission_serializer.ex @@ -0,0 +1,86 @@ +defmodule CodincodApiWeb.Serializers.SubmissionSerializer do + @moduledoc """ + Serializes `CodincodApi.Submissions.Submission` structs for HTTP responses. + """ + + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApiWeb.Serializers.Helpers + alias CodincodApiWeb.Serializers.UserSerializer + + @spec render(Submission.t()) :: map() + def render(%Submission{} = submission) do + %{ + _id: submission.id, + id: submission.id, + legacyId: submission.legacy_id, + code: submission.code, + result: submission.result || %{}, + score: submission.score, + createdAt: Helpers.format_datetime(submission.inserted_at), + updatedAt: Helpers.format_datetime(submission.updated_at), + puzzle: render_puzzle(submission.puzzle, submission.puzzle_id), + programmingLanguage: + render_programming_language( + submission.programming_language, + submission.programming_language_id + ), + user: render_user(submission.user, submission.user_id), + gameId: submission.game_id, + legacyGameSubmissionId: submission.legacy_game_submission_id + } + end + + @spec render_many([Submission.t()]) :: [map()] + def render_many(submissions) when is_list(submissions) do + Enum.map(submissions, &render/1) + end + + defp render_user(%User{} = user, _id), do: UserSerializer.render(user) + defp render_user(_user, nil), do: nil + + defp render_user(_user, id) do + %{ + _id: id, + id: id + } + end + + defp render_puzzle(%Puzzle{} = puzzle, _id) do + %{ + _id: puzzle.id, + id: puzzle.id, + title: puzzle.title + } + end + + defp render_puzzle(_puzzle, nil), do: nil + + defp render_puzzle(_puzzle, id) do + %{ + _id: id, + id: id + } + end + + defp render_programming_language(%ProgrammingLanguage{} = language, _id) do + %{ + _id: language.id, + id: language.id, + language: language.language, + version: language.version, + runtime: language.runtime + } + end + + defp render_programming_language(_language, nil), do: nil + + defp render_programming_language(_language, id) do + %{ + _id: id, + id: id + } + end +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex b/libs/backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex new file mode 100644 index 00000000..f37d31c8 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/serializers/user_serializer.ex @@ -0,0 +1,29 @@ +defmodule CodincodApiWeb.Serializers.UserSerializer do + @moduledoc """ + Serializes `CodincodApi.Accounts.User` structs into API responses consistent with the + legacy Node implementation. + """ + + alias CodincodApi.Accounts.User + alias CodincodApiWeb.Serializers.Helpers + + @spec render(User.t() | nil) :: map() | nil + def render(%User{} = user) do + %{ + _id: user.id, + id: user.id, + legacyId: user.legacy_id, + legacyUsername: user.legacy_username, + username: user.username, + profile: user.profile || %{}, + role: user.role, + reportCount: user.report_count, + banCount: user.ban_count, + currentBan: user.current_ban_id, + createdAt: Helpers.format_datetime(user.inserted_at), + updatedAt: Helpers.format_datetime(user.updated_at) + } + end + + def render(_), do: nil +end diff --git a/libs/backend/codincod_api/lib/codincod_api_web/telemetry.ex b/libs/backend/codincod_api/lib/codincod_api_web/telemetry.ex new file mode 100644 index 00000000..8cf1ef41 --- /dev/null +++ b/libs/backend/codincod_api/lib/codincod_api_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule CodincodApiWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("codincod_api.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("codincod_api.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("codincod_api.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("codincod_api.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("codincod_api.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {CodincodApiWeb, :count_users, []} + ] + end +end diff --git a/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex new file mode 100644 index 00000000..e7974f3c --- /dev/null +++ b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_openapi_spec.ex @@ -0,0 +1,37 @@ +defmodule Mix.Tasks.Codincod.GenOpenapiSpec do + @moduledoc "Generate OpenAPI specification JSON from the Phoenix router." + + use Mix.Task + + @shortdoc "Emit OpenAPI JSON" + + @switches [dest: :string] + @aliases [d: :dest] + + @impl Mix.Task + def run(args) do + Mix.Task.run("app.start") + + {opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) + + dest = + opts + |> Keyword.get(:dest, default_destination()) + |> Path.expand(File.cwd!()) + + spec = CodincodApiWeb.OpenAPI.spec() + + # Use render_spec instead of to_map to properly resolve references + json = spec + |> OpenApiSpex.OpenApi.json_encoder().encode!(pretty: true) + + :ok = File.mkdir_p!(Path.dirname(dest)) + :ok = File.write(dest, json) + + Mix.shell().info("OpenAPI spec written to #{dest}") + end + + defp default_destination do + Path.join(["priv", "static", "openapi.json"]) + end +end diff --git a/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex new file mode 100644 index 00000000..d47945a0 --- /dev/null +++ b/libs/backend/codincod_api/lib/mix/tasks/codincod.gen_types.ex @@ -0,0 +1,27 @@ +defmodule Mix.Tasks.Codincod.GenTypes do + @moduledoc "Generates TypeScript definitions that mirror the Phoenix backend." + + use Mix.Task + + @shortdoc "Generate TypeScript types for the frontend" + + @switches [dest: :string] + @aliases [d: :dest] + + @impl Mix.Task + def run(args) do + Mix.Task.run("compile") + + {opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) + + opts = Keyword.take(opts, [:dest]) + + case CodincodApi.Typegen.generate(opts) do + {:ok, path} -> + Mix.shell().info("TypeScript definitions written to #{path}") + + {:error, reason} -> + Mix.raise("Failed to generate TypeScript types: #{inspect(reason)}") + end + end +end diff --git a/libs/backend/codincod_api/lib/mix/tasks/migrate_mongo.ex b/libs/backend/codincod_api/lib/mix/tasks/migrate_mongo.ex new file mode 100644 index 00000000..95b3f100 --- /dev/null +++ b/libs/backend/codincod_api/lib/mix/tasks/migrate_mongo.ex @@ -0,0 +1,1107 @@ +defmodule Mix.Tasks.MigrateMongo do + @moduledoc """ + Migrates data from MongoDB to PostgreSQL. + + This task is idempotent and can be safely re-run multiple times. + It will skip already-migrated records based on legacy_mongo_id. + + ## Usage + + # Migrate everything (recommended order) + mix migrate_mongo + + # Migrate specific collections + mix migrate_mongo --only users + mix migrate_mongo --only puzzles + mix migrate_mongo --only submissions + + # Dry run (show what would be migrated) + mix migrate_mongo --dry-run + + # Validate migration without migrating + mix migrate_mongo --validate + + ## Environment Variables + + MONGO_URI - MongoDB connection string + MONGO_DB_NAME - MongoDB database name (default: codincod-development) + + ## Migration Order (important!) + + 1. Users (no dependencies) + 2. Puzzles (depends on users for author_id) + 3. Submissions (depends on users and puzzles) + 4. Games (depends on users and puzzles) + 5. Comments (depends on users and puzzles) + 6. Reports (depends on users) + 7. Preferences (depends on users) + """ + + use Mix.Task + require Logger + + alias CodincodApi.Repo + alias CodincodApi.Accounts.{User, Preference} + alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} + alias CodincodApi.Submissions.Submission + alias CodincodApi.Games.Game + alias CodincodApi.Comments.Comment + alias CodincodApi.Moderation.Report + + @shortdoc "Migrates data from MongoDB to PostgreSQL" + + @batch_size 100 + + def run(args) do + Mix.Task.run("app.start") + + {opts, _, _} = OptionParser.parse(args, + switches: [only: :string, dry_run: :boolean, validate: :boolean], + aliases: [o: :only, d: :dry_run, v: :validate] + ) + + mongo_uri = System.get_env("MONGO_URI") || + raise "MONGO_URI environment variable required" + mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + + ssl_opts = if String.contains?(mongo_uri, "mongodb+srv://") do + [verify: :verify_none] + else + [] + end + + Logger.info("🚀 Starting MongoDB → PostgreSQL migration") + Logger.info(" Database: #{mongo_db}") + + case Mongo.start_link(url: mongo_uri, name: :mongo_migration, database: mongo_db, pool_size: 5, ssl_opts: ssl_opts) do + {:ok, _pid} -> + cond do + opts[:validate] -> + validate_migration() + opts[:dry_run] -> + dry_run(opts[:only]) + true -> + perform_migration(opts[:only]) + end + + {:error, reason} -> + Logger.error("❌ Failed to connect to MongoDB: #{inspect(reason)}") + exit(:mongodb_connection_failed) + end + end + + defp perform_migration(only) do + migrations = case only do + "users" -> [:users] + "puzzles" -> [:puzzles] + "submissions" -> [:submissions] + "games" -> [:games] + "comments" -> [:comments] + "reports" -> [:reports] + "preferences" -> [:preferences] + nil -> [:users, :puzzles, :submissions, :games, :comments, :reports, :preferences] + _ -> + Logger.error("Unknown collection: #{only}") + exit(:invalid_collection) + end + + start_time = System.monotonic_time(:millisecond) + + results = Enum.map(migrations, fn migration -> + case migration do + :users -> migrate_users() + :puzzles -> migrate_puzzles() + :submissions -> migrate_submissions() + :games -> migrate_games() + :comments -> migrate_comments() + :reports -> migrate_reports() + :preferences -> migrate_preferences() + end + end) + + duration = System.monotonic_time(:millisecond) - start_time + + Logger.info("\n" <> IO.ANSI.green() <> "✅ Migration completed in #{duration}ms" <> IO.ANSI.reset()) + print_summary(results) + end + + defp migrate_users do + Logger.info("\n📊 Migrating users...") + + case Mongo.find(:mongo_migration, "users", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No users found in MongoDB") + %{collection: "users", migrated: 0, skipped: 0, failed: 0} + + mongo_users -> + total = length(mongo_users) + Logger.info(" Found #{total} users in MongoDB") + + {migrated, skipped, failed} = mongo_users + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn user, {migrated, skipped, failed} -> + case migrate_single_user(user) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Users: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "users", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_user(mongo_user) do + mongo_id = extract_mongo_id(mongo_user["_id"]) + + # Check if already migrated + case Repo.get_by(User, legacy_id: mongo_id) do + %User{} = _existing -> + {:ok, :skipped} + + nil -> + # Build profile from MongoDB structure + profile = %{} + |> Map.put("avatarUrl", get_in(mongo_user, ["profile", "avatarUrl"]) || get_in(mongo_user, ["profile", "picture"])) + |> Map.put("bio", get_in(mongo_user, ["profile", "bio"])) + |> Map.put("location", get_in(mongo_user, ["profile", "location"])) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.into(%{}) + + # Sanitize username to match regex ^[A-Za-z0-9_-]+$ + raw_username = mongo_user["username"] || mongo_user["email"] |> String.split("@") |> hd() + sanitized_username = raw_username + |> String.replace(~r/[^A-Za-z0-9_-]/, "_") + |> String.slice(0, 20) + + attrs = %{ + email: mongo_user["email"], + username: sanitized_username, + password: "TemporaryPassword123!", # Will use actual hash below + password_confirmation: "TemporaryPassword123!", + profile: profile, + role: parse_role(mongo_user["role"]), + legacy_id: mongo_id, + legacy_username: raw_username, # Store original username + ban_count: mongo_user["banCount"] || 0 + } + + changeset = User.registration_changeset(%User{}, attrs) + + # Override the password_hash with the actual MongoDB hash + changeset = if mongo_user["password"] do + Ecto.Changeset.put_change(changeset, :password_hash, mongo_user["password"]) + else + changeset + end + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_user["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_user["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, _user} -> + {:ok, :created} + + {:error, changeset} -> + Logger.error(" Failed to migrate user #{mongo_user["email"]}: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + + defp migrate_puzzles do + Logger.info("\n🧩 Migrating puzzles...") + + case Mongo.find(:mongo_migration, "puzzles", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No puzzles found") + %{collection: "puzzles", migrated: 0, skipped: 0, failed: 0} + + mongo_puzzles -> + total = length(mongo_puzzles) + Logger.info(" Found #{total} puzzles") + + {migrated, skipped, failed} = mongo_puzzles + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn puzzle, {migrated, skipped, failed} -> + case migrate_single_puzzle(puzzle) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Puzzles: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "puzzles", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_puzzle(mongo_puzzle) do + mongo_id = extract_mongo_id(mongo_puzzle["_id"]) + + case Repo.get_by(Puzzle, legacy_id: mongo_id) do + %Puzzle{} -> {:ok, :skipped} + nil -> + # Find author by legacy_id + author_mongo_id = extract_mongo_id(mongo_puzzle["author"]) + author = Repo.get_by(User, legacy_id: author_mongo_id) + + if is_nil(author) do + Logger.warning(" Skipping puzzle '#{mongo_puzzle["title"]}' - author not found (#{author_mongo_id})") + {:error, :author_not_found} + else + # Clean solution field from BSON ObjectIds + solution = clean_bson_objectids(mongo_puzzle["solution"] || %{}) + + attrs = %{ + title: mongo_puzzle["title"] || "Untitled Puzzle", + statement: mongo_puzzle["statement"] || mongo_puzzle["description"] || "", + constraints: mongo_puzzle["constraints"], + difficulty: parse_difficulty(mongo_puzzle["difficulty"]), + visibility: parse_visibility(mongo_puzzle["visibility"]), + tags: mongo_puzzle["tags"] || [], + solution: solution, + author_id: author.id, + legacy_id: mongo_id + } + + changeset = Puzzle.changeset(%Puzzle{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_puzzle["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_puzzle["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, puzzle} -> + # Migrate test cases to their own table + # Validators can be at top level or in solution field + migrate_test_cases(puzzle, mongo_puzzle, solution, mongo_id) + + # Migrate examples to their own table + migrate_examples(puzzle, solution, mongo_id) + + {:ok, :created} + {:error, changeset} -> + Logger.error(" Failed to migrate puzzle '#{mongo_puzzle["title"]}': #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_test_cases(puzzle, mongo_puzzle, solution, mongo_id) do + # MongoDB stores test cases in "validators" array at top level + # Also check solution field and "testCases" for backward compatibility + test_cases = mongo_puzzle["validators"] || solution["testCases"] || solution["validators"] || [] + + test_cases + |> Enum.with_index() + |> Enum.each(fn {tc, idx} -> + # Check if already exists by legacy_id + legacy_id = "#{mongo_id}_tc_#{idx}" + + unless Repo.get_by(PuzzleTestCase, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: tc["input"] || "", + # MongoDB uses "output", newer format might use "expectedOutput" + expected_output: tc["expectedOutput"] || tc["output"] || "", + # Default to false if not specified (hidden test cases) + is_sample: tc["isSample"] || tc["is_sample"] || false, + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleTestCase.changeset(%PuzzleTestCase{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to migrate test case #{idx} for puzzle #{puzzle.title}: #{inspect(changeset.errors)}") + end + end + end) + end + + defp migrate_examples(puzzle, solution, mongo_id) do + examples = solution["examples"] || [] + + examples + |> Enum.with_index() + |> Enum.each(fn {ex, idx} -> + # Check if already exists by legacy_id + legacy_id = "#{mongo_id}_ex_#{idx}" + + unless Repo.get_by(PuzzleExample, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: ex["input"] || "", + output: ex["output"] || "", + explanation: ex["explanation"], + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleExample.changeset(%PuzzleExample{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to migrate example #{idx} for puzzle #{puzzle.title}: #{inspect(changeset.errors)}") + end + end + end) + end + + defp migrate_submissions do + Logger.info("\n📝 Migrating submissions...") + + case Mongo.find(:mongo_migration, "submissions", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No submissions found") + %{collection: "submissions", migrated: 0, skipped: 0, failed: 0} + + mongo_submissions -> + total = length(mongo_submissions) + Logger.info(" Found #{total} submissions") + + {migrated, skipped, failed} = mongo_submissions + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + Enum.reduce(batch, {m, s, f}, fn submission, {migrated, skipped, failed} -> + case migrate_single_submission(submission) do + {:ok, :created} -> {migrated + 1, skipped, failed} + {:ok, :skipped} -> {migrated, skipped + 1, failed} + {:error, _} -> {migrated, skipped, failed + 1} + end + end) + end) + + Logger.info(" ✓ Submissions: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "submissions", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_submission(mongo_submission) do + mongo_id = extract_mongo_id(mongo_submission["_id"]) + + case Repo.get_by(Submission, legacy_id: mongo_id) do + %Submission{} -> {:ok, :skipped} + nil -> + user_mongo_id = extract_mongo_id(mongo_submission["user"]) + puzzle_mongo_id = extract_mongo_id(mongo_submission["puzzle"]) + + user = Repo.get_by(User, legacy_id: user_mongo_id) + puzzle = Repo.get_by(Puzzle, legacy_id: puzzle_mongo_id) + + cond do + is_nil(user) -> + {:error, :user_not_found} + is_nil(puzzle) -> + {:error, :puzzle_not_found} + true -> + # Get or create programming language + language_data = mongo_submission["programmingLanguage"] + language_name = cond do + is_struct(language_data, BSON.ObjectId) -> "unknown" + is_map(language_data) -> language_data["language"] + is_binary(language_data) -> language_data + true -> "unknown" + end + + programming_language = get_or_create_language(language_name || "unknown") + + result = mongo_submission["result"] || %{} + # Clean BSON ObjectIds from result + result = clean_bson_objectids(result) + + attrs = %{ + user_id: user.id, + puzzle_id: puzzle.id, + programming_language_id: programming_language && programming_language.id, + code: mongo_submission["code"] || "", + result: result, + score: calculate_score(result), + legacy_id: mongo_id + } + + changeset = Submission.create_changeset(%Submission{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, parse_datetime(mongo_submission["createdAt"]) || DateTime.utc_now()) + |> Ecto.Changeset.put_change(:updated_at, parse_datetime(mongo_submission["updatedAt"]) || DateTime.utc_now()) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate submission: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_games do + Logger.info("\n🎮 Migrating games...") + + case Mongo.find(:mongo_migration, "games", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No games found in MongoDB") + %{collection: "games", migrated: 0, skipped: 0, failed: 0} + + mongo_games -> + total = length(mongo_games) + Logger.info(" Found #{total} games") + + {migrated, skipped, failed} = mongo_games + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_game/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Games: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "games", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_game(mongo_game) do + mongo_id = mongo_game["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Game, legacy_id: mongo_id) do + %Game{} -> {:ok, :skipped} + nil -> + # Get owner + owner = case mongo_game["owner"] do + %BSON.ObjectId{} = oid -> + owner_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: owner_id) + _ -> nil + end + + # Get puzzle + puzzle = case mongo_game["puzzle"] do + %BSON.ObjectId{} = oid -> + puzzle_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Puzzle, legacy_id: puzzle_id) + _ -> nil + end + + cond do + is_nil(owner) -> + {:error, :owner_not_found} + is_nil(puzzle) -> + {:error, :puzzle_not_found} + true -> + # Clean BSON ObjectIds from options + options = mongo_game["options"] || %{} + options = clean_bson_objectids(options) + + # Parse timestamps + started_at = parse_datetime(mongo_game["startedAt"]) + ended_at = parse_datetime(mongo_game["endedAt"]) + created_at = parse_datetime(mongo_game["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_game["updatedAt"]) || DateTime.utc_now() + + attrs = %{ + owner_id: owner.id, + puzzle_id: puzzle.id, + visibility: parse_game_visibility(mongo_game["visibility"]), + mode: parse_game_mode(mongo_game["mode"]), + rated: mongo_game["ranked"] || true, + status: parse_game_status(mongo_game["status"]), + max_duration_seconds: mongo_game["maxDuration"] || 600, + allowed_language_ids: [], + options: options, + started_at: started_at, + ended_at: ended_at, + legacy_id: mongo_id + } + + changeset = Game.changeset(%Game{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate game: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_comments do + Logger.info("\n💬 Migrating comments...") + + case Mongo.find(:mongo_migration, "comments", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No comments found in MongoDB") + %{collection: "comments", migrated: 0, skipped: 0, failed: 0} + + mongo_comments -> + total = length(mongo_comments) + Logger.info(" Found #{total} comments") + + {migrated, skipped, failed} = mongo_comments + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_comment/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Comments: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "comments", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_comment(mongo_comment) do + mongo_id = mongo_comment["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Comment, legacy_id: mongo_id) do + %Comment{} -> {:ok, :skipped} + nil -> + # Get author + author = case mongo_comment["author"] do + %BSON.ObjectId{} = oid -> + author_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: author_id) + _ -> nil + end + + # Get puzzle (optional) + puzzle = case mongo_comment["puzzle"] do + %BSON.ObjectId{} = oid -> + puzzle_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Puzzle, legacy_id: puzzle_id) + _ -> nil + end + + # Get parent comment (optional) + parent_comment = case mongo_comment["parent"] do + %BSON.ObjectId{} = oid -> + parent_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(Comment, legacy_id: parent_id) + _ -> nil + end + + if is_nil(author) do + {:error, :author_not_found} + else + # Parse timestamps + created_at = parse_datetime(mongo_comment["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_comment["updatedAt"]) || DateTime.utc_now() + + # Get votes + votes = mongo_comment["votes"] || %{} + upvotes = if is_list(votes["up"]), do: length(votes["up"]), else: 0 + downvotes = if is_list(votes["down"]), do: length(votes["down"]), else: 0 + + attrs = %{ + author_id: author.id, + puzzle_id: puzzle && puzzle.id, + parent_comment_id: parent_comment && parent_comment.id, + body: mongo_comment["text"] || "", + comment_type: parse_comment_type(mongo_comment["commentType"], parent_comment), + upvote_count: upvotes, + downvote_count: downvotes, + metadata: %{}, + legacy_id: mongo_id + } + + changeset = Comment.changeset(%Comment{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate comment: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_reports do + Logger.info("\n🚨 Migrating reports...") + + case Mongo.find(:mongo_migration, "reports", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No reports found in MongoDB") + %{collection: "reports", migrated: 0, skipped: 0, failed: 0} + + mongo_reports -> + total = length(mongo_reports) + Logger.info(" Found #{total} reports") + + {migrated, skipped, failed} = mongo_reports + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_report/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Reports: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "reports", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_report(mongo_report) do + mongo_id = mongo_report["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Report, legacy_id: mongo_id) do + %Report{} -> {:ok, :skipped} + nil -> + # Get reporter + reporter = case mongo_report["reportedBy"] do + %BSON.ObjectId{} = oid -> + reporter_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: reporter_id) + _ -> nil + end + + if is_nil(reporter) do + {:error, :reporter_not_found} + else + # Get problem reference ID and try to find the PostgreSQL UUID + problem_ref_id = case {mongo_report["problematicCollection"], mongo_report["problematicIdentifier"]} do + {collection, %BSON.ObjectId{} = oid} when not is_nil(collection) -> + legacy_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Try to find the migrated entity's PostgreSQL UUID + case String.downcase(collection || "") do + "users" -> + case Repo.get_by(User, legacy_id: legacy_id) do + %User{id: id} -> id + _ -> nil + end + "puzzles" -> + case Repo.get_by(Puzzle, legacy_id: legacy_id) do + %Puzzle{id: id} -> id + _ -> nil + end + "comments" -> + case Repo.get_by(Comment, legacy_id: legacy_id) do + %Comment{id: id} -> id + _ -> nil + end + "games" -> + case Repo.get_by(Game, legacy_id: legacy_id) do + %Game{id: id} -> id + _ -> nil + end + _ -> nil + end + _ -> nil + end + + # If problem_ref_id is still nil, generate a placeholder UUID (referenced entity doesn't exist in PostgreSQL) + # The snapshot field contains the original data anyway + problem_ref_id = problem_ref_id || Ecto.UUID.generate() + + # Parse timestamps + created_at = parse_datetime(mongo_report["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_report["updatedAt"]) || DateTime.utc_now() + resolved_at = parse_datetime(mongo_report["resolvedAt"]) + + # Get explanation (min 10 chars required) + explanation = case mongo_report["reason"] do + nil -> "No explanation provided (migrated from legacy data)" + "" -> "No explanation provided (migrated from legacy data)" + reason when is_binary(reason) and byte_size(reason) < 10 -> + "#{reason} (migrated from legacy data)" + reason -> reason + end + + attrs = %{ + reported_by_id: reporter.id, + problem_type: parse_problem_type(mongo_report["problematicCollection"]), + problem_reference_id: problem_ref_id, + problem_reference_snapshot: clean_bson_objectids(mongo_report["snapshot"] || %{}), + explanation: explanation, + status: parse_report_status(mongo_report["status"]), + resolution_notes: mongo_report["resolutionNotes"], + resolved_at: resolved_at, + metadata: %{}, + legacy_id: mongo_id + } + + # For migration, we bypass the strict validation and build changeset manually + # since many reports may not have valid problem_reference_ids in PostgreSQL + changeset = %Report{} + |> Ecto.Changeset.cast(attrs, [ + :legacy_id, + :problem_type, + :problem_reference_id, + :problem_reference_snapshot, + :explanation, + :status, + :metadata, + :reported_by_id, + :resolution_notes, + :resolved_at + ]) + |> Ecto.Changeset.validate_required([:problem_type, :explanation, :reported_by_id]) + |> Ecto.Changeset.validate_length(:explanation, min: 10, max: 2_000) + |> Ecto.Changeset.validate_inclusion(:problem_type, ["puzzle", "user", "comment", "game_chat"]) + |> Ecto.Changeset.validate_inclusion(:status, ["pending", "resolved", "rejected"]) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate report: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp migrate_preferences do + Logger.info("\n⚙️ Migrating preferences...") + + case Mongo.find(:mongo_migration, "preferences", %{}) |> Enum.to_list() do + [] -> + Logger.warning(" No preferences found in MongoDB") + %{collection: "preferences", migrated: 0, skipped: 0, failed: 0} + + mongo_preferences -> + total = length(mongo_preferences) + Logger.info(" Found #{total} preferences") + + {migrated, skipped, failed} = mongo_preferences + |> Enum.chunk_every(@batch_size) + |> Enum.with_index(1) + |> Enum.reduce({0, 0, 0}, fn {batch, batch_num}, {m, s, f} -> + Logger.info(" Processing batch #{batch_num}/#{ceil(total / @batch_size)}") + + batch_results = Enum.map(batch, &migrate_single_preference/1) + + migrated_count = Enum.count(batch_results, &match?({:ok, :created}, &1)) + skipped_count = Enum.count(batch_results, &match?({:ok, :skipped}, &1)) + failed_count = Enum.count(batch_results, &match?({:error, _}, &1)) + + {m + migrated_count, s + skipped_count, f + failed_count} + end) + + Logger.info(" ✓ Preferences: #{migrated} migrated, #{skipped} skipped, #{failed} failed") + %{collection: "preferences", migrated: migrated, skipped: skipped, failed: failed, total: total} + end + end + + defp migrate_single_preference(mongo_preference) do + mongo_id = mongo_preference["_id"] |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + + # Check if already migrated + case Repo.get_by(Preference, legacy_id: mongo_id) do + %Preference{} -> {:ok, :skipped} + nil -> + # Get user - try "owner", "userId", and "user" fields + user = case mongo_preference["owner"] || mongo_preference["userId"] || mongo_preference["user"] do + %BSON.ObjectId{} = oid -> + user_id = oid |> BSON.ObjectId.encode!() |> Base.encode16(case: :lower) + Repo.get_by(User, legacy_id: user_id) + user_id when is_binary(user_id) -> + # Already a string ID + Repo.get_by(User, legacy_id: user_id) + _ -> nil + end + + if is_nil(user) do + Logger.debug(" User not found for preference: #{mongo_id}, user field: #{inspect(mongo_preference["owner"] || mongo_preference["userId"] || mongo_preference["user"])}") + {:error, :user_not_found} + else + # Parse timestamps + created_at = parse_datetime(mongo_preference["createdAt"]) || DateTime.utc_now() + updated_at = parse_datetime(mongo_preference["updatedAt"]) || DateTime.utc_now() + + # Clean editor config + editor = clean_bson_objectids(mongo_preference["editor"] || %{}) + + attrs = %{ + user_id: user.id, + preferred_language: mongo_preference["preferredLanguage"], + theme: parse_theme(mongo_preference["theme"]), + blocked_user_ids: [], + editor: editor, + legacy_id: mongo_id + } + + changeset = Preference.changeset(%Preference{}, attrs) + + # Set timestamps + changeset = changeset + |> Ecto.Changeset.put_change(:inserted_at, created_at) + |> Ecto.Changeset.put_change(:updated_at, updated_at) + + case Repo.insert(changeset) do + {:ok, _} -> {:ok, :created} + {:error, changeset} -> + Logger.debug(" Failed to migrate preference: #{inspect(changeset.errors)}") + {:error, changeset} + end + end + end + end + + defp validate_migration do + Logger.info("\n🔍 Validating migration...") + + validations = [ + {"users", count_mongo("users"), Repo.aggregate(User, :count)}, + {"puzzles", count_mongo("puzzles"), Repo.aggregate(Puzzle, :count)}, + {"submissions", count_mongo("submissions"), Repo.aggregate(Submission, :count)}, + {"games", count_mongo("games"), Repo.aggregate(Game, :count)}, + {"comments", count_mongo("comments"), Repo.aggregate(Comment, :count)}, + {"reports", count_mongo("reports"), Repo.aggregate(Report, :count)}, + {"preferences", count_mongo("preferences"), Repo.aggregate(Preference, :count)} + ] + + Enum.each(validations, fn {name, mongo_count, pg_count} -> + status = if mongo_count == pg_count, do: "✅", else: "❌" + Logger.info(" #{status} #{String.pad_trailing(name, 15)} MongoDB: #{mongo_count}, PostgreSQL: #{pg_count}") + end) + + Logger.info("\n✅ Validation complete") + end + + defp dry_run(only) do + Logger.info("\n🔍 DRY RUN - No data will be migrated\n") + + collections = case only do + nil -> ["users", "puzzles", "submissions", "games", "comments", "reports", "preferences"] + collection -> [collection] + end + + Enum.each(collections, fn collection -> + mongo_count = count_mongo(collection) + pg_count = case collection do + "users" -> Repo.aggregate(User, :count) + "puzzles" -> Repo.aggregate(Puzzle, :count) + "submissions" -> Repo.aggregate(Submission, :count) + _ -> 0 + end + + to_migrate = mongo_count - pg_count + Logger.info(" #{collection}: #{mongo_count} in MongoDB, #{pg_count} in PostgreSQL → would migrate #{max(0, to_migrate)}") + end) + end + + defp print_summary(results) do + Logger.info("\n📊 Migration Summary:") + Logger.info(" " <> String.duplicate("=", 60)) + + Enum.each(results, fn result -> + collection = String.pad_trailing(result.collection, 15) + + if Map.has_key?(result, :note) do + Logger.info(" #{collection} - #{result.note}") + else + migrated = String.pad_leading("#{result.migrated}", 4) + skipped = String.pad_leading("#{result.skipped}", 4) + failed = String.pad_leading("#{result.failed}", 4) + total = Map.get(result, :total, result.migrated + result.skipped + result.failed) + + Logger.info(" #{collection} - #{migrated} migrated, #{skipped} skipped, #{failed} failed (#{total} total)") + end + end) + + Logger.info(" " <> String.duplicate("=", 60)) + end + + # Helper functions + + defp extract_mongo_id(%BSON.ObjectId{} = oid), do: BSON.ObjectId.encode!(oid) |> Base.encode16(case: :lower) + defp extract_mongo_id(id) when is_binary(id), do: id + defp extract_mongo_id(_), do: nil + + defp parse_datetime(%DateTime{} = dt) do + # Ensure microsecond precision for :utc_datetime_usec + %{dt | microsecond: {elem(dt.microsecond, 0), 6}} + end + defp parse_datetime(nil), do: nil + defp parse_datetime(_), do: DateTime.utc_now() + + defp parse_role("admin"), do: "admin" + defp parse_role("moderator"), do: "moderator" + defp parse_role(_), do: "user" + + defp parse_difficulty("BEGINNER"), do: "BEGINNER" + defp parse_difficulty("EASY"), do: "EASY" + defp parse_difficulty("MEDIUM"), do: "INTERMEDIATE" + defp parse_difficulty("INTERMEDIATE"), do: "INTERMEDIATE" + defp parse_difficulty("HARD"), do: "HARD" + defp parse_difficulty("EXPERT"), do: "EXPERT" + defp parse_difficulty(_), do: "INTERMEDIATE" + + defp parse_visibility("APPROVED"), do: "APPROVED" + defp parse_visibility("REVIEW"), do: "REVIEW" + defp parse_visibility("DRAFT"), do: "DRAFT" + defp parse_visibility("REVISE"), do: "REVISE" + defp parse_visibility("INACTIVE"), do: "INACTIVE" + defp parse_visibility(_), do: "DRAFT" + + defp parse_submission_status("success"), do: :accepted + defp parse_submission_status("error"), do: :wrong_answer + defp parse_submission_status(_), do: :pending + + defp calculate_score(%{"result" => "success"}), do: 100.0 + defp calculate_score(%{"result" => "error"}), do: 0.0 + defp calculate_score(_), do: nil + + defp parse_game_visibility("public"), do: "public" + defp parse_game_visibility("private"), do: "private" + defp parse_game_visibility("friends"), do: "friends" + defp parse_game_visibility(_), do: "public" + + defp parse_game_mode("FASTEST"), do: "FASTEST" + defp parse_game_mode("SHORTEST"), do: "SHORTEST" + defp parse_game_mode("BACKWARDS"), do: "BACKWARDS" + defp parse_game_mode("HARDCORE"), do: "HARDCORE" + defp parse_game_mode("DEBUG"), do: "DEBUG" + defp parse_game_mode("TYPERACER"), do: "TYPERACER" + defp parse_game_mode("EFFICIENCY"), do: "EFFICIENCY" + defp parse_game_mode("INCREMENTAL"), do: "INCREMENTAL" + defp parse_game_mode("RANDOM"), do: "RANDOM" + defp parse_game_mode(_), do: "FASTEST" + + defp parse_game_status("waiting"), do: "waiting" + defp parse_game_status("in_progress"), do: "in_progress" + defp parse_game_status("completed"), do: "completed" + defp parse_game_status("cancelled"), do: "cancelled" + defp parse_game_status(_), do: "waiting" + + defp parse_comment_type(nil, nil), do: "puzzle-comment" + defp parse_comment_type(nil, _parent), do: "comment-comment" + defp parse_comment_type("puzzle-comment", _), do: "puzzle-comment" + defp parse_comment_type("comment-comment", _), do: "comment-comment" + defp parse_comment_type(_, nil), do: "puzzle-comment" + defp parse_comment_type(_, _parent), do: "comment-comment" + + defp parse_problem_type("puzzles"), do: "puzzle" + defp parse_problem_type("users"), do: "user" + defp parse_problem_type("comments"), do: "comment" + defp parse_problem_type("game_chat"), do: "game_chat" + defp parse_problem_type(_), do: "puzzle" + + defp parse_report_status("pending"), do: "pending" + defp parse_report_status("resolved"), do: "resolved" + defp parse_report_status("rejected"), do: "rejected" + defp parse_report_status(_), do: "pending" + + defp parse_theme("dark"), do: "dark" + defp parse_theme("light"), do: "light" + defp parse_theme(_), do: nil + + defp get_or_create_language(language_name) do + alias CodincodApi.Languages.ProgrammingLanguage + + case Repo.get_by(ProgrammingLanguage, language: language_name) do + %ProgrammingLanguage{} = lang -> + lang + + nil -> + # Create it if it doesn't exist + attrs = %{ + language: language_name, + version: "unknown", + runtime: "unknown" + } + + case Repo.insert(ProgrammingLanguage.changeset(%ProgrammingLanguage{}, attrs)) do + {:ok, lang} -> lang + {:error, _} -> nil + end + end + rescue + _ -> nil + end + + defp generate_slug(title) do + title + |> String.downcase() + |> String.replace(~r/[^a-z0-9\s-]/, "") + |> String.replace(~r/\s+/, "-") + |> String.slice(0, 100) + end + + defp count_mongo(collection) do + case Mongo.count_documents(:mongo_migration, collection, %{}) do + {:ok, count} -> count + _ -> 0 + end + rescue + _ -> 0 + end + + defp clean_bson_objectids(%BSON.ObjectId{} = oid) do + BSON.ObjectId.encode!(oid) |> Base.encode16(case: :lower) + end + + defp clean_bson_objectids(data) when is_map(data) do + data + |> Enum.map(fn {k, v} -> {k, clean_bson_objectids(v)} end) + |> Enum.into(%{}) + end + + defp clean_bson_objectids(data) when is_list(data) do + Enum.map(data, &clean_bson_objectids/1) + end + + defp clean_bson_objectids(data), do: data +end diff --git a/libs/backend/codincod_api/lib/mix/tasks/mongo.inspect.ex b/libs/backend/codincod_api/lib/mix/tasks/mongo.inspect.ex new file mode 100644 index 00000000..f0249eef --- /dev/null +++ b/libs/backend/codincod_api/lib/mix/tasks/mongo.inspect.ex @@ -0,0 +1,89 @@ +defmodule Mix.Tasks.Mongo.Inspect do + @moduledoc """ + Inspects MongoDB database to show what data is available for migration. + + Usage: + mix mongo.inspect + """ + + use Mix.Task + require Logger + + @shortdoc "Inspect MongoDB database contents" + + def run(_args) do + Mix.Task.run("app.start") + + # MongoDB connection from TypeScript backend env + mongo_uri = System.get_env("MONGO_URI") || "mongodb://codincod-dev:hunter2@localhost:27017" + mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + + Logger.info("Connecting to MongoDB: #{mongo_db}") + Logger.info("URI: #{String.replace(mongo_uri, ~r/:[^:@]+@/, ":***@")}") + + # MongoDB Atlas requires SSL with CA certs + ssl_opts = if String.contains?(mongo_uri, "mongodb+srv://") do + [ + verify: :verify_none # For development - disable cert verification + ] + else + [] + end + + case Mongo.start_link(url: mongo_uri, name: :mongo, database: mongo_db, pool_size: 2, ssl_opts: ssl_opts) do + {:ok, _pid} -> + inspect_database(mongo_db) + # Don't call Mongo.stop - just let it terminate naturally + :ok + {:error, reason} -> + Logger.error("Failed to connect to MongoDB: #{inspect(reason)}") + Logger.error("Make sure MongoDB is running and MONGO_URI is correct") + end + end + + defp inspect_database(database) do + IO.puts("\n" <> IO.ANSI.cyan() <> "=== MongoDB Database: #{database} ===" <> IO.ANSI.reset() <> "\n") + + collections = [ + "users", + "puzzles", + "submissions", + "games", + "programming_languages", + "programmingLanguages", + "comments", + "reports", + "user_metrics", + "usermetrics", + "preferences" + ] + + Enum.each(collections, fn collection -> + count = count_documents(collection) + + if count > 0 do + IO.puts("#{IO.ANSI.green()}✓#{IO.ANSI.reset()} #{String.pad_trailing(collection, 25)} #{IO.ANSI.yellow()}#{count}#{IO.ANSI.reset()} documents") + + # Show sample document + case Mongo.find_one(:mongo, collection, %{}) do + nil -> :ok + doc -> + IO.puts(" Sample keys: #{inspect(Map.keys(doc) |> Enum.take(10))}") + end + else + IO.puts("#{IO.ANSI.red()}✗#{IO.ANSI.reset()} #{String.pad_trailing(collection, 25)} (empty)") + end + end) + + IO.puts("\n") + end + + defp count_documents(collection) do + case Mongo.count_documents(:mongo, collection, %{}) do + {:ok, count} -> count + _ -> 0 + end + rescue + _ -> 0 + end +end diff --git a/libs/backend/codincod_api/mix.exs b/libs/backend/codincod_api/mix.exs new file mode 100644 index 00000000..d93264ea --- /dev/null +++ b/libs/backend/codincod_api/mix.exs @@ -0,0 +1,114 @@ +defmodule CodincodApi.MixProject do + use Mix.Project + + def project do + [ + app: :codincod_api, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {CodincodApi.Application, []}, + extra_applications: [:logger, :runtime_tools, :os_mon, :crypto] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + # Phoenix Framework + {:phoenix, "~> 1.8.1"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + + # Authentication & Security + {:pbkdf2_elixir, "~> 2.0"}, + {:guardian, "~> 2.3"}, + {:comeonin, "~> 5.4"}, + + # API & Utilities + {:cors_plug, "~> 3.0"}, + {:plug_crypto, "~> 2.1"}, + + # HTTP Client for Piston + {:finch, "~> 0.19"}, + {:tesla, "~> 1.13"}, + + # Background Jobs + {:oban, "~> 2.18"}, + + # Rate Limiting + {:hammer, "~> 6.2"}, + {:hammer_plug, "~> 3.1"}, + + # MongoDB (for migration) + {:mongodb_driver, "~> 1.5"}, + + # OpenAPI generation + {:open_api_spex, "~> 3.18"}, + + # WebSockets/Channels + {:phoenix_pubsub, "~> 2.1"}, + + # Caching + {:cachex, "~> 4.0"}, + + # Development & Testing + {:phoenix_live_reload, "~> 1.5", only: :dev}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_machina, "~> 2.8", only: :test}, + {:faker, "~> 0.18", only: [:dev, :test]}, + {:mix_test_watch, "~> 1.2", only: [:dev, :test], runtime: false} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/libs/backend/codincod_api/mix.lock b/libs/backend/codincod_api/mix.lock new file mode 100644 index 00000000..99f0cecd --- /dev/null +++ b/libs/backend/codincod_api/mix.lock @@ -0,0 +1,67 @@ +%{ + "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, + "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, + "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, + "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, + "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, + "hammer_plug": {:hex, :hammer_plug, "3.2.0", "47db6ed67d5cdf09fb6035f26b0b4b2335c3ae08a7ac061e3303bbb756fe9a09", [:mix], [{:hammer, "~> 6.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "1ee7084732414c7a32f467717d13e6fba95c60b70c3f56d51f7c08a4183aadfe"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, + "mongodb_driver": {:hex, :mongodb_driver, "1.5.6", "7dc920872d3a65821c12aebde2cbf62002961498f3739c04b1f08c0800538dc5", [:mix], [{:db_connection, "~> 2.6", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, ">= 2.1.1 and < 3.0.0-0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fdb83112e8aab60b690e382b7e0d2e9d848bd81a40bcdaf4dfcd14af5d7ab882"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "2.3.1", "073866b593887365d0ff50bb806d860a50f454bcda49b5b6f4658c9173c53889", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "ab4da7db8aeb2db20e02a1d416cbb46d0690658aafb4396878acef8748c9c319"}, + "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.16", "e42f95337b912a73a1c4ddb077af2eb13491712d7ab79b67e13de4237dfcac50", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f2a0093895b8ef4880af76d41de4a9cf7cff6c66ad130e15a70bdabc4d279feb"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, + "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, +} diff --git a/libs/backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po b/libs/backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 00000000..844c4f5c --- /dev/null +++ b/libs/backend/codincod_api/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/libs/backend/codincod_api/priv/gettext/errors.pot b/libs/backend/codincod_api/priv/gettext/errors.pot new file mode 100644 index 00000000..eef2de2b --- /dev/null +++ b/libs/backend/codincod_api/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/libs/backend/codincod_api/priv/repo/migrations/.formatter.exs b/libs/backend/codincod_api/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs new file mode 100644 index 00000000..f6ae5128 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090000_create_accounts_tables.exs @@ -0,0 +1,66 @@ +defmodule CodincodApi.Repo.Migrations.CreateAccountsTables do + use Ecto.Migration + + def change do + execute("CREATE EXTENSION IF NOT EXISTS citext;", "") + + create table(:users, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :legacy_username, :string + add :username, :citext, null: false + add :email, :citext, null: false + add :password_hash, :string, null: false + add :profile, :map, null: false, default: fragment("'{}'::jsonb") + add :role, :string, null: false, default: "user" + add :report_count, :integer, null: false, default: 0 + add :ban_count, :integer, null: false, default: 0 + add :legacy_current_ban_id, :string + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:users, [:username]) + create unique_index(:users, [:email]) + create index(:users, [:role]) + create index(:users, [:inserted_at]) + + create table(:user_bans, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :banned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :ban_type, :string, null: false + add :reason, :text + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + add :expires_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:user_bans, [:user_id]) + create index(:user_bans, [:ban_type]) + create index(:user_bans, [:expires_at]) + + alter table(:users) do + add :current_ban_id, references(:user_bans, type: :binary_id, on_delete: :nilify_all) + end + + create index(:users, [:current_ban_id]) + + create table(:user_preferences, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :preferred_language, :string + add :theme, :string + add :blocked_user_ids, {:array, :binary_id}, null: false, default: [] + add :editor, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:user_preferences, [:user_id]) + create index(:user_preferences, [:preferred_language]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs new file mode 100644 index 00000000..f553b073 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090100_create_programming_languages.exs @@ -0,0 +1,22 @@ +defmodule CodincodApi.Repo.Migrations.CreateProgrammingLanguages do + use Ecto.Migration + + def change do + create table(:programming_languages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :language, :string, null: false + add :version, :string, null: false + add :aliases, {:array, :string}, null: false, default: [] + add :runtime, :string + add :display_order, :integer + add :is_active, :boolean, null: false, default: true + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:programming_languages, [:language, :version]) + create index(:programming_languages, [:is_active]) + create index(:programming_languages, [:display_order]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs new file mode 100644 index 00000000..fece76d5 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090300_create_puzzles_tables.exs @@ -0,0 +1,64 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzlesTables do + use Ecto.Migration + + def change do + execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;", "") + execute("CREATE EXTENSION IF NOT EXISTS btree_gin;", "") + + create table(:puzzles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :title, :string, null: false + add :statement, :text + add :constraints, :text + add :author_id, references(:users, type: :binary_id, on_delete: :nothing), null: false + add :difficulty, :string, null: false + add :visibility, :string, null: false + add :tags, {:array, :string}, null: false, default: [] + add :solution, :map, null: false, default: fragment("'{}'::jsonb") + add :moderation_feedback, :text + add :legacy_metrics_id, :string + add :legacy_comments, {:array, :string}, null: false, default: [] + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzles, [:author_id]) + create index(:puzzles, [:difficulty]) + create index(:puzzles, [:visibility]) + create index(:puzzles, [:inserted_at]) + + execute( + "CREATE INDEX puzzles_tags_gin_index ON puzzles USING gin (tags);", + "DROP INDEX IF EXISTS puzzles_tags_gin_index;" + ) + + create table(:puzzle_validators, primary_key: false) do + add :id, :binary_id, primary_key: true + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :legacy_id, :string + add :input, :text, null: false + add :output, :text, null: false + add :is_public, :boolean, null: false, default: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_validators, [:puzzle_id]) + create index(:puzzle_validators, [:is_public]) + + create table(:puzzle_metrics, primary_key: false) do + add :id, :binary_id, primary_key: true + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :legacy_id, :string + add :attempt_count, :integer, null: false, default: 0 + add :success_count, :integer, null: false, default: 0 + add :average_execution_ms, :float, null: false, default: 0.0 + add :average_code_length, :integer, null: false, default: 0 + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:puzzle_metrics, [:puzzle_id]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs new file mode 100644 index 00000000..e743d599 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090400_create_submissions_tables.exs @@ -0,0 +1,27 @@ +defmodule CodincodApi.Repo.Migrations.CreateSubmissionsTables do + use Ecto.Migration + + def change do + create table(:submissions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + + add :programming_language_id, + references(:programming_languages, type: :binary_id, on_delete: :restrict), + null: false + + add :code, :text, null: false + add :result, :map, null: false, default: fragment("'{}'::jsonb") + add :score, :float + add :legacy_game_submission_id, :string + + timestamps(type: :utc_datetime_usec) + end + + create index(:submissions, [:puzzle_id]) + create index(:submissions, [:user_id]) + create index(:submissions, [:inserted_at]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs new file mode 100644 index 00000000..824f252b --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090500_create_games_tables.exs @@ -0,0 +1,52 @@ +defmodule CodincodApi.Repo.Migrations.CreateGamesTables do + use Ecto.Migration + + def change do + create table(:games, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :owner_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :restrict), null: false + add :visibility, :string, null: false + add :mode, :string, null: false + add :rated, :boolean, null: false, default: true + add :status, :string, null: false, default: "waiting" + add :max_duration_seconds, :integer, null: false, default: 600 + add :allowed_language_ids, {:array, :binary_id}, null: false, default: [] + add :options, :map, null: false, default: fragment("'{}'::jsonb") + add :started_at, :utc_datetime_usec + add :ended_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:games, [:owner_id]) + create index(:games, [:puzzle_id]) + create index(:games, [:status]) + create index(:games, [:mode]) + create index(:games, [:visibility]) + + create table(:game_players, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :joined_at, :utc_datetime_usec, null: false + add :left_at, :utc_datetime_usec + add :role, :string, null: false, default: "player" + add :score, :integer + add :placement, :integer + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:game_players, [:game_id, :user_id]) + create index(:game_players, [:role]) + + alter table(:submissions) do + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all) + end + + create index(:submissions, [:game_id]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs new file mode 100644 index 00000000..fba90797 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090600_create_comments_tables.exs @@ -0,0 +1,43 @@ +defmodule CodincodApi.Repo.Migrations.CreateCommentsTables do + use Ecto.Migration + + def change do + create table(:comments, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :author_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all) + add :submission_id, references(:submissions, type: :binary_id, on_delete: :delete_all) + add :parent_comment_id, references(:comments, type: :binary_id, on_delete: :delete_all) + add :body, :text, null: false + add :comment_type, :string, null: false, default: "comment" + add :upvote_count, :integer, null: false, default: 0 + add :downvote_count, :integer, null: false, default: 0 + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + add :deleted_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:comments, [:author_id]) + create index(:comments, [:puzzle_id]) + create index(:comments, [:submission_id]) + create index(:comments, [:parent_comment_id]) + create index(:comments, [:comment_type]) + + create table(:comment_votes, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :comment_id, references(:comments, type: :binary_id, on_delete: :delete_all), + null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :vote_type, :string, null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:comment_votes, [:comment_id, :user_id]) + create index(:comment_votes, [:vote_type]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs new file mode 100644 index 00000000..bbf9eb91 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090700_create_reports_and_chat.exs @@ -0,0 +1,63 @@ +defmodule CodincodApi.Repo.Migrations.CreateReportsAndChat do + use Ecto.Migration + + def change do + create table(:reports, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :problem_type, :string, null: false + add :problem_reference_id, :binary_id, null: false + add :problem_reference_snapshot, :map, null: false, default: fragment("'{}'::jsonb") + + add :reported_by_id, references(:users, type: :binary_id, on_delete: :delete_all), + null: false + + add :resolved_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :explanation, :text, null: false + add :status, :string, null: false, default: "pending" + add :resolution_notes, :text + add :resolved_at, :utc_datetime_usec + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create index(:reports, [:problem_type]) + create index(:reports, [:status]) + create index(:reports, [:reported_by_id]) + create index(:reports, [:resolved_by_id]) + + create table(:moderation_reviews, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + add :reviewer_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :status, :string, null: false, default: "pending" + add :notes, :text + add :submitted_at, :utc_datetime_usec, null: false, default: fragment("now()") + add :resolved_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:moderation_reviews, [:puzzle_id]) + create index(:moderation_reviews, [:status]) + + create table(:chat_messages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :game_id, references(:games, type: :binary_id, on_delete: :delete_all), null: false + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :username_snapshot, :string, null: false + add :message, :text, null: false + add :is_deleted, :boolean, null: false, default: false + add :deleted_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create index(:chat_messages, [:game_id]) + create index(:chat_messages, [:user_id]) + create index(:chat_messages, [:inserted_at]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs b/libs/backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs new file mode 100644 index 00000000..95f4e11d --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251101090800_create_metrics_tables.exs @@ -0,0 +1,36 @@ +defmodule CodincodApi.Repo.Migrations.CreateMetricsTables do + use Ecto.Migration + + def change do + create table(:user_metrics, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :global_rating, :float, null: false, default: 1500.0 + add :global_rating_deviation, :float, null: false, default: 350.0 + add :global_rating_volatility, :float, null: false, default: 0.06 + add :modes, :map, null: false, default: fragment("'{}'::jsonb") + add :totals, :map, null: false, default: fragment("'{}'::jsonb") + add :last_processed_game_at, :utc_datetime_usec + add :last_calculated_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:user_metrics, [:user_id]) + create index(:user_metrics, [:global_rating]) + + create table(:leaderboard_snapshots, primary_key: false) do + add :id, :binary_id, primary_key: true + add :game_mode, :string, null: false + add :captured_at, :utc_datetime_usec, null: false + add :entries, :map, null: false, default: fragment("'[]'::jsonb") + add :metadata, :map, null: false, default: fragment("'{}'::jsonb") + + timestamps(type: :utc_datetime_usec) + end + + create index(:leaderboard_snapshots, [:game_mode]) + create index(:leaderboard_snapshots, [:captured_at]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs new file mode 100644 index 00000000..1c09ce34 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251102000001_create_puzzle_test_cases.exs @@ -0,0 +1,24 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzleTestCases do + use Ecto.Migration + + def change do + create table(:puzzle_test_cases, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + + add :input, :text, null: false + add :expected_output, :text, null: false + add :is_sample, :boolean, default: false, null: false + add :order, :integer, null: false + add :metadata, :map, default: %{}, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_test_cases, [:puzzle_id]) + create index(:puzzle_test_cases, [:puzzle_id, :order]) + create index(:puzzle_test_cases, [:puzzle_id, :is_sample]) + create unique_index(:puzzle_test_cases, [:legacy_id], where: "legacy_id IS NOT NULL") + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs new file mode 100644 index 00000000..58132f2c --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251102000002_create_puzzle_examples.exs @@ -0,0 +1,23 @@ +defmodule CodincodApi.Repo.Migrations.CreatePuzzleExamples do + use Ecto.Migration + + def change do + create table(:puzzle_examples, primary_key: false) do + add :id, :binary_id, primary_key: true + add :legacy_id, :string + add :puzzle_id, references(:puzzles, type: :binary_id, on_delete: :delete_all), null: false + + add :input, :text, null: false + add :output, :text, null: false + add :explanation, :text + add :order, :integer, null: false + add :metadata, :map, default: %{}, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:puzzle_examples, [:puzzle_id]) + create index(:puzzle_examples, [:puzzle_id, :order]) + create unique_index(:puzzle_examples, [:legacy_id], where: "legacy_id IS NOT NULL") + end +end diff --git a/libs/backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs b/libs/backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs new file mode 100644 index 00000000..6df3b2d2 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/migrations/20251102154346_create_password_resets.exs @@ -0,0 +1,19 @@ +defmodule CodincodApi.Repo.Migrations.CreatePasswordResets do + use Ecto.Migration + + def change do + create table(:password_resets, primary_key: false) do + add :id, :binary_id, primary_key: true + add :token, :string, null: false + add :expires_at, :utc_datetime_usec, null: false + add :used_at, :utc_datetime_usec + add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:password_resets, [:token]) + create index(:password_resets, [:user_id]) + create index(:password_resets, [:expires_at]) + end +end diff --git a/libs/backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs b/libs/backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs new file mode 100644 index 00000000..4c3d5cdf --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/scripts/extract_puzzle_sub_schemas.exs @@ -0,0 +1,93 @@ +# Script to extract test cases and examples from puzzle.solution JSONB field +# into their own tables (puzzle_test_cases and puzzle_examples) +# +# Run with: mix run priv/repo/scripts/extract_puzzle_sub_schemas.exs + +require Logger + +alias CodincodApi.Repo +alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} + +import Ecto.Query + +Logger.info("🔄 Extracting puzzle test cases and examples...") + +# Get all puzzles with solution data +puzzles_with_solutions = + from(p in Puzzle, + where: not is_nil(p.solution), + where: p.solution != ^%{}, + preload: [:test_cases, :examples] + ) + |> Repo.all() + +Logger.info("Found #{length(puzzles_with_solutions)} puzzles with solution data") + +Enum.each(puzzles_with_solutions, fn puzzle -> + solution = puzzle.solution || %{} + + # Extract test cases + test_cases = solution["testCases"] || [] + Logger.info(" Processing puzzle '#{puzzle.title}' - #{length(test_cases)} test cases, #{length(solution["examples"] || [])} examples") + + test_cases + |> Enum.with_index() + |> Enum.each(fn {tc, idx} -> + legacy_id = "#{puzzle.legacy_id}_tc_#{idx}" + + # Skip if already exists + unless Repo.get_by(PuzzleTestCase, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: tc["input"] || "", + expected_output: tc["expectedOutput"] || tc["output"] || "", + is_sample: tc["isSample"] || false, + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleTestCase.changeset(%PuzzleTestCase{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to insert test case #{idx}: #{inspect(changeset.errors)}") + end + end + end) + + # Extract examples + examples = solution["examples"] || [] + + examples + |> Enum.with_index() + |> Enum.each(fn {ex, idx} -> + legacy_id = "#{puzzle.legacy_id}_ex_#{idx}" + + # Skip if already exists + unless Repo.get_by(PuzzleExample, legacy_id: legacy_id) do + attrs = %{ + puzzle_id: puzzle.id, + input: ex["input"] || "", + output: ex["output"] || "", + explanation: ex["explanation"], + order: idx, + legacy_id: legacy_id, + metadata: %{} + } + + case PuzzleExample.changeset(%PuzzleExample{}, attrs) |> Repo.insert() do + {:ok, _} -> :ok + {:error, changeset} -> + Logger.warning(" Failed to insert example #{idx}: #{inspect(changeset.errors)}") + end + end + end) +end) + +# Count results +test_case_count = Repo.aggregate(PuzzleTestCase, :count) +example_count = Repo.aggregate(PuzzleExample, :count) + +Logger.info("✅ Extraction complete!") +Logger.info(" Total test cases: #{test_case_count}") +Logger.info(" Total examples: #{example_count}") diff --git a/libs/backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs b/libs/backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs new file mode 100644 index 00000000..e8571071 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/scripts/seed_test_data_mongodb.exs @@ -0,0 +1,367 @@ +#!/usr/bin/env elixir + +# Script to seed MongoDB with test data for migration verification +# This creates known test objects that we can verify after migration +# +# Usage: +# MONGO_URI="..." MONGO_DB_NAME="..." mix run priv/repo/scripts/seed_test_data_mongodb.exs + +require Logger + +# Deterministic seed for reproducible test data +:rand.seed(:exsplus, {42, 42, 42}) + +# Generate deterministic test IDs +defmodule TestData do + def generate_object_id(prefix) do + # Create deterministic ObjectIds for testing + # MongoDB ObjectId is 12 bytes (24 hex chars) + hash = :crypto.hash(:md5, prefix) |> binary_part(0, 12) + %BSON.ObjectId{value: hash} + end + + def test_timestamp(days_ago \\ 0) do + DateTime.utc_now() + |> DateTime.add(-days_ago * 24 * 3600, :second) + end + + # Test user data + def test_user(index) do + %{ + "_id" => generate_object_id("test_user_#{index}"), + "username" => "test_user_#{index}", + "email" => "test#{index}@migration-test.com", + "password" => "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyJSawHJK7tW", # "password123" + "role" => if(index == 1, do: "admin", else: "user"), + "isActive" => true, + "createdAt" => test_timestamp(30), + "updatedAt" => test_timestamp(20) + } + end + + # Test puzzle data + def test_puzzle(index, author_id) do + %{ + "_id" => generate_object_id("test_puzzle_#{index}"), + "title" => "Test Puzzle #{index}: Two Sum", + "statement" => "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.", + "constraints" => "- Each input has exactly one solution\n- You may not use the same element twice\n- Array length: 2 ≤ n ≤ 10^4", + "author" => author_id, + "difficulty" => Enum.at(["beginner", "intermediate", "advanced", "expert"], rem(index, 4)), + "visibility" => "approved", + "tags" => ["array", "hash-table", "test-migration"], + "validators" => [ + %{ + "input" => "[2,7,11,15]\n9", + "output" => "[0,1]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + }, + %{ + "input" => "[3,2,4]\n6", + "output" => "[1,2]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + }, + %{ + "input" => "[3,3]\n6", + "output" => "[0,1]", + "createdAt" => test_timestamp(25), + "updatedAt" => test_timestamp(25) + } + ], + "solution" => %{ + "code" => "def two_sum(nums, target):\n seen = {}\n for i, num in enumerate(nums):\n complement = target - num\n if complement in seen:\n return [seen[complement], i]\n seen[num] = i\n return []", + "programmingLanguage" => generate_object_id("lang_python"), + "explanation" => "Use a hash map to store numbers we've seen and check for complements.", + "examples" => [ + %{ + "input" => "[2,7,11,15]\n9", + "output" => "[0,1]", + "explanation" => "nums[0] + nums[1] = 2 + 7 = 9" + } + ] + }, + "createdAt" => test_timestamp(28), + "updatedAt" => test_timestamp(15) + } + end + + # Test submission data + def test_submission(index, user_id, puzzle_id) do + statuses = ["pending", "accepted", "wrong_answer", "runtime_error", "time_limit_exceeded"] + status = Enum.at(statuses, rem(index, 5)) + + %{ + "_id" => generate_object_id("test_submission_#{index}"), + "user" => user_id, + "puzzle" => puzzle_id, + "code" => "def solution(nums, target):\n # Test submission #{index}\n return [0, 1]", + "programmingLanguage" => %{ + "_id" => generate_object_id("lang_python"), + "name" => "python", + "version" => "3.11.0" + }, + "status" => status, + "result" => %{ + "testResults" => [ + %{"passed" => true, "executionTime" => 45}, + %{"passed" => status == "accepted", "executionTime" => 52}, + %{"passed" => status == "accepted", "executionTime" => 38} + ], + "totalTests" => 3, + "passedTests" => if(status == "accepted", do: 3, else: 1), + "executionTime" => 135, + "memoryUsed" => 15_234_567 + }, + "createdAt" => test_timestamp(10 + index), + "updatedAt" => test_timestamp(10 + index) + } + end + + # Test game data + def test_game(index, player_ids, puzzle_id) do + [owner_id | _] = player_ids # First player is the owner + + %{ + "_id" => generate_object_id("test_game_#{index}"), + "owner" => owner_id, # Add owner field + "puzzle" => puzzle_id, + "players" => player_ids, + "status" => Enum.at(["waiting", "in_progress", "completed"], rem(index, 3)), + "mode" => Enum.at(["competitive", "collaborative", "practice"], rem(index, 3)), # Changed from gameMode + "visibility" => "public", # Add visibility + "options" => %{ + "timeLimit" => 3600, + "maxPlayers" => length(player_ids), + "allowLateJoin" => true, + "showLeaderboard" => true, + "difficulty" => "intermediate" + }, + "scores" => Enum.map(player_ids, fn player_id -> + %{ + "player" => player_id, + "score" => :rand.uniform(1000), + "completedAt" => test_timestamp(5) + } + end), + "createdAt" => test_timestamp(12), + "updatedAt" => test_timestamp(5) + } + end + + # Test comment data + def test_comment(index, author_id, puzzle_id) do + %{ + "_id" => generate_object_id("test_comment_#{index}"), + "author" => author_id, + "puzzle" => puzzle_id, + "text" => "This is test comment #{index}. Great puzzle! I learned a lot about hash tables.", + "commentType" => Enum.at(["discussion", "solution", "question"], rem(index, 3)), + "votes" => %{ + "up" => [author_id], + "down" => [] + }, + "createdAt" => test_timestamp(8), + "updatedAt" => test_timestamp(7) + } + end + + # Test report data + def test_report(index, reporter_id, puzzle_id) do + %{ + "_id" => generate_object_id("test_report_#{index}"), + "reportedBy" => reporter_id, # Changed from "reporter" to match schema + "problematicCollection" => "puzzles", # Add collection type + "problematicIdentifier" => puzzle_id, # Changed from problemReferenceId + "problemType" => Enum.at(["puzzle", "comment", "user"], rem(index, 3)), + "problemReferenceSnapshot" => %{ + "title" => "Test Puzzle #{index}", + "statement" => "Original statement...", + "capturedAt" => test_timestamp(6) + }, + "reason" => "Test report #{index}: This content violates community guidelines.", # Use reason instead of description + "status" => Enum.at(["pending", "reviewed", "resolved"], rem(index, 3)), + "createdAt" => test_timestamp(6), + "updatedAt" => test_timestamp(4) + } + end + + # Test preference data + def test_preference(index, user_id) do + %{ + "_id" => generate_object_id("test_pref_#{index}"), + "owner" => user_id, # MongoDB uses "owner" instead of "user" + "editor" => %{ + "theme" => Enum.at(["light", "dark", "monokai"], rem(index, 3)), + "fontSize" => 12 + rem(index, 4), + "tabSize" => 2 + rem(index, 2), + "wordWrap" => rem(index, 2) == 0, + "autoComplete" => true, + "keyBindings" => "default" + }, + "notifications" => %{ + "email" => true, + "push" => false, + "comments" => true, + "submissions" => true + }, + "createdAt" => test_timestamp(15), + "updatedAt" => test_timestamp(3) + } + end +end + +# Connect to MongoDB +Logger.info("🔌 Connecting to MongoDB...") + +mongo_uri = System.get_env("MONGO_URI") || raise "MONGO_URI environment variable required" +mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + +# Parse connection string to check if it's Atlas (mongodb+srv) +is_atlas = String.starts_with?(mongo_uri, "mongodb+srv://") + +connect_opts = [ + url: mongo_uri, + name: :mongo_test_seed, + database: mongo_db, + pool_size: 1 +] + +# Add SSL options for Atlas +connect_opts = if is_atlas do + Keyword.merge(connect_opts, [ + ssl: true, + ssl_opts: [verify: :verify_none] + ]) +else + connect_opts +end + +{:ok, conn} = Mongo.start_link(connect_opts) + +Logger.info("✅ Connected to MongoDB: #{mongo_db}") +Logger.info("📝 Creating test data with deterministic values...") + +# Create test data +try do + # 1. Create test users (5 users) + Logger.info("\n👤 Creating test users...") + test_users = for i <- 1..5, do: TestData.test_user(i) + + # Delete existing test users + Mongo.delete_many(conn, "users", %{"email" => %{"$regex" => "@migration-test.com"}}) + + # Insert test users + {:ok, _} = Mongo.insert_many(conn, "users", test_users) + Logger.info(" Created #{length(test_users)} test users") + + # Get user IDs + user_ids = Enum.map(test_users, & &1["_id"]) + [author_id | other_user_ids] = user_ids + + # 2. Create test puzzles (3 puzzles) + Logger.info("\n🧩 Creating test puzzles...") + test_puzzles = for i <- 1..3, do: TestData.test_puzzle(i, author_id) + + Mongo.delete_many(conn, "puzzles", %{"tags" => "test-migration"}) + {:ok, _} = Mongo.insert_many(conn, "puzzles", test_puzzles) + Logger.info(" Created #{length(test_puzzles)} test puzzles") + + puzzle_ids = Enum.map(test_puzzles, & &1["_id"]) + [puzzle_id | _] = puzzle_ids + + # 3. Create test submissions (10 submissions) + Logger.info("\n📝 Creating test submissions...") + test_submissions = for i <- 1..10 do + TestData.test_submission( + i, + Enum.at(user_ids, rem(i, length(user_ids))), + Enum.at(puzzle_ids, rem(i, length(puzzle_ids))) + ) + end + + # Delete test submissions + submission_ids = Enum.map(test_submissions, & &1["_id"]) + Mongo.delete_many(conn, "submissions", %{"_id" => %{"$in" => submission_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "submissions", test_submissions) + Logger.info(" Created #{length(test_submissions)} test submissions") + + # 4. Create test games (4 games) + Logger.info("\n🎮 Creating test games...") + test_games = for i <- 1..4 do + player_count = 2 + rem(i, 3) + players = Enum.take(user_ids, player_count) + TestData.test_game(i, players, Enum.at(puzzle_ids, rem(i, length(puzzle_ids)))) + end + + game_ids = Enum.map(test_games, & &1["_id"]) + Mongo.delete_many(conn, "games", %{"_id" => %{"$in" => game_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "games", test_games) + Logger.info(" Created #{length(test_games)} test games") + + # 5. Create test comments (6 comments) + Logger.info("\n💬 Creating test comments...") + test_comments = for i <- 1..6 do + TestData.test_comment( + i, + Enum.at(user_ids, rem(i, length(user_ids))), + Enum.at(puzzle_ids, rem(i, length(puzzle_ids))) + ) + end + + comment_ids = Enum.map(test_comments, & &1["_id"]) + Mongo.delete_many(conn, "comments", %{"_id" => %{"$in" => comment_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "comments", test_comments) + Logger.info(" Created #{length(test_comments)} test comments") + + # 6. Create test reports (3 reports) + Logger.info("\n🚩 Creating test reports...") + test_reports = for i <- 1..3 do + TestData.test_report(i, author_id, Enum.at(puzzle_ids, rem(i, length(puzzle_ids)))) + end + + report_ids = Enum.map(test_reports, & &1["_id"]) + Mongo.delete_many(conn, "reports", %{"_id" => %{"$in" => report_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "reports", test_reports) + Logger.info(" Created #{length(test_reports)} test reports") + + # 7. Create test preferences (5 preferences - one per user) + Logger.info("\n⚙️ Creating test preferences...") + test_preferences = for i <- 1..5 do + TestData.test_preference(i, Enum.at(user_ids, i - 1)) + end + + # Delete existing test preferences by ID + pref_ids = Enum.map(test_preferences, & &1["_id"]) + Mongo.delete_many(conn, "preferences", %{"_id" => %{"$in" => pref_ids}}) + Mongo.delete_many(conn, "preferences", %{"owner" => %{"$in" => user_ids}}) + + {:ok, _} = Mongo.insert_many(conn, "preferences", test_preferences) + Logger.info(" Created #{length(test_preferences)} test preferences") + + # Summary + Logger.info("\n" <> String.duplicate("=", 60)) + Logger.info("✅ Test Data Creation Complete!") + Logger.info(String.duplicate("=", 60)) + Logger.info("📊 Summary:") + Logger.info(" • Users: #{length(test_users)}") + Logger.info(" • Puzzles: #{length(test_puzzles)}") + Logger.info(" • Submissions: #{length(test_submissions)}") + Logger.info(" • Games: #{length(test_games)}") + Logger.info(" • Comments: #{length(test_comments)}") + Logger.info(" • Reports: #{length(test_reports)}") + Logger.info(" • Preferences: #{length(test_preferences)}") + Logger.info(" " <> String.duplicate("-", 58)) + Logger.info(" Total: #{length(test_users) + length(test_puzzles) + length(test_submissions) + length(test_games) + length(test_comments) + length(test_reports) + length(test_preferences)}") + Logger.info(String.duplicate("=", 60)) + Logger.info("\n🚀 Ready for migration testing!") + Logger.info(" Run: mix migrate_mongo") + +after + GenServer.stop(conn) +end diff --git a/libs/backend/codincod_api/priv/repo/scripts/verify_migration.exs b/libs/backend/codincod_api/priv/repo/scripts/verify_migration.exs new file mode 100644 index 00000000..2b3dc88f --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/scripts/verify_migration.exs @@ -0,0 +1,559 @@ +#!/usr/bin/env elixir + +# Script to verify MongoDB to PostgreSQL migration accuracy +# Compares test objects in MongoDB with their migrated counterparts in PostgreSQL +# +# Usage: +# MONGO_URI="..." MONGO_DB_NAME="..." mix run priv/repo/scripts/verify_migration.exs + +require Logger +import Ecto.Query + +alias CodincodApi.Repo +alias CodincodApi.Accounts.{User, Preference} +alias CodincodApi.Puzzles.{Puzzle, PuzzleTestCase, PuzzleExample} +alias CodincodApi.Submissions.Submission +alias CodincodApi.Games.Game +alias CodincodApi.Comments.Comment +alias CodincodApi.Moderation.Report + +defmodule MigrationVerifier do + require Logger + + def generate_test_object_id(prefix) do + # Generate same deterministic ObjectIds as seed script + hash = :crypto.hash(:md5, prefix) |> binary_part(0, 12) + %BSON.ObjectId{value: hash} + end + + def extract_mongo_id(%BSON.ObjectId{value: value}), do: Base.encode16(value, case: :lower) + def extract_mongo_id(value) when is_binary(value) and byte_size(value) == 12 do + Base.encode16(value, case: :lower) + end + def extract_mongo_id(value) when is_binary(value), do: value + def extract_mongo_id(_), do: nil + + def verify_user(mongo_user, pg_user) do + errors = [] + + errors = if mongo_user["username"] != pg_user.username do + ["Username mismatch: #{mongo_user["username"]} != #{pg_user.username}" | errors] + else + errors + end + + errors = if mongo_user["email"] != pg_user.email do + ["Email mismatch: #{mongo_user["email"]} != #{pg_user.email}" | errors] + else + errors + end + + errors = if mongo_user["role"] != pg_user.role do + ["Role mismatch: #{mongo_user["role"]} != #{pg_user.role}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ User '#{pg_user.username}' verified"} + else + {:error, "❌ User '#{pg_user.username}' has mismatches", errors} + end + end + + def verify_puzzle(mongo_puzzle, pg_puzzle, conn) do + errors = [] + + errors = if mongo_puzzle["title"] != pg_puzzle.title do + ["Title mismatch: #{mongo_puzzle["title"]} != #{pg_puzzle.title}" | errors] + else + errors + end + + errors = if mongo_puzzle["statement"] != pg_puzzle.statement do + ["Statement mismatch" | errors] + else + errors + end + + errors = if mongo_puzzle["constraints"] != pg_puzzle.constraints do + ["Constraints mismatch" | errors] + else + errors + end + + # Verify difficulty + mongo_difficulty = String.downcase(mongo_puzzle["difficulty"] || "") + pg_difficulty = String.downcase(pg_puzzle.difficulty || "") + errors = if mongo_difficulty != pg_difficulty do + ["Difficulty mismatch: #{mongo_difficulty} != #{pg_difficulty}" | errors] + else + errors + end + + # Verify tags + mongo_tags = Enum.sort(mongo_puzzle["tags"] || []) + pg_tags = Enum.sort(pg_puzzle.tags || []) + errors = if mongo_tags != pg_tags do + ["Tags mismatch: #{inspect(mongo_tags)} != #{inspect(pg_tags)}" | errors] + else + errors + end + + # Verify test cases (validators in MongoDB) + mongo_validators = mongo_puzzle["validators"] || [] + pg_test_cases = Repo.all( + from tc in PuzzleTestCase, + where: tc.puzzle_id == ^pg_puzzle.id, + order_by: [asc: tc.order] + ) + + if length(mongo_validators) != length(pg_test_cases) do + errors = ["Test case count mismatch: #{length(mongo_validators)} != #{length(pg_test_cases)}" | errors] + else + # Verify each test case + validator_errors = Enum.zip(mongo_validators, pg_test_cases) + |> Enum.with_index() + |> Enum.flat_map(fn {{mv, tc}, idx} -> + tc_errors = [] + tc_errors = if mv["input"] != tc.input do + ["TC#{idx} input mismatch" | tc_errors] + else + tc_errors + end + + tc_errors = if mv["output"] != tc.expected_output do + ["TC#{idx} output mismatch: #{mv["output"]} != #{tc.expected_output}" | tc_errors] + else + tc_errors + end + + tc_errors + end) + + errors = errors ++ validator_errors + end + + # Verify examples if present + mongo_examples = get_in(mongo_puzzle, ["solution", "examples"]) || [] + pg_examples = Repo.all( + from ex in PuzzleExample, + where: ex.puzzle_id == ^pg_puzzle.id, + order_by: [asc: ex.order] + ) + + if length(mongo_examples) > 0 and length(mongo_examples) != length(pg_examples) do + errors = ["Example count mismatch: #{length(mongo_examples)} != #{length(pg_examples)}" | errors] + end + + if errors == [] do + {:ok, "✅ Puzzle '#{pg_puzzle.title}' verified (#{length(pg_test_cases)} test cases, #{length(pg_examples)} examples)"} + else + {:error, "❌ Puzzle '#{pg_puzzle.title}' has mismatches", errors} + end + end + + def verify_submission(mongo_sub, pg_sub) do + errors = [] + + errors = if mongo_sub["code"] != pg_sub.code do + ["Code mismatch" | errors] + else + errors + end + + # Verify status + mongo_status = String.downcase(mongo_sub["status"] || "pending") + pg_status = String.downcase(Atom.to_string(pg_sub.status)) + + # Map MongoDB statuses to PostgreSQL + status_map = %{ + "accepted" => "accepted", + "wrong_answer" => "wrong_answer", + "runtime_error" => "runtime_error", + "time_limit_exceeded" => "time_limit_exceeded", + "pending" => "pending" + } + + expected_status = Map.get(status_map, mongo_status, mongo_status) + errors = if expected_status != pg_status do + ["Status mismatch: #{mongo_status} != #{pg_status}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Submission verified (status: #{pg_status})"} + else + {:error, "❌ Submission has mismatches", errors} + end + end + + def verify_game(mongo_game, pg_game) do + errors = [] + + # Verify player count + mongo_player_count = length(mongo_game["players"] || []) + pg_player_count = length(pg_game.player_ids || []) + + errors = if mongo_player_count != pg_player_count do + ["Player count mismatch: #{mongo_player_count} != #{pg_player_count}" | errors] + else + errors + end + + # Verify game mode + mongo_mode = String.downcase(mongo_game["gameMode"] || "") + pg_mode = String.downcase(Atom.to_string(pg_game.game_mode)) + + errors = if mongo_mode != pg_mode do + ["Game mode mismatch: #{mongo_mode} != #{pg_mode}" | errors] + else + errors + end + + # Verify options are preserved + mongo_options = mongo_game["options"] || %{} + pg_options = pg_game.options || %{} + + errors = if is_map(mongo_options) and map_size(mongo_options) > 0 and map_size(pg_options) == 0 do + ["Options not migrated" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Game verified (#{pg_player_count} players, mode: #{pg_mode})"} + else + {:error, "❌ Game has mismatches", errors} + end + end + + def verify_comment(mongo_comment, pg_comment) do + errors = [] + + errors = if mongo_comment["text"] != pg_comment.text do + ["Text mismatch" | errors] + else + errors + end + + # Verify comment type + mongo_type = String.downcase(mongo_comment["commentType"] || "discussion") + pg_type = String.downcase(Atom.to_string(pg_comment.comment_type)) + + errors = if mongo_type != pg_type do + ["Type mismatch: #{mongo_type} != #{pg_type}" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Comment verified (type: #{pg_type})"} + else + {:error, "❌ Comment has mismatches", errors} + end + end + + def verify_report(mongo_report, pg_report) do + errors = [] + + errors = if mongo_report["description"] != pg_report.description do + ["Description mismatch" | errors] + else + errors + end + + # Verify reason + mongo_reason = String.downcase(mongo_report["reason"] || "") + pg_reason = String.downcase(Atom.to_string(pg_report.reason)) + + errors = if mongo_reason != pg_reason do + ["Reason mismatch: #{mongo_reason} != #{pg_reason}" | errors] + else + errors + end + + # Verify snapshot is preserved + mongo_snapshot = mongo_report["problemReferenceSnapshot"] + pg_snapshot = pg_report.problem_reference_snapshot + + errors = if is_map(mongo_snapshot) and is_nil(pg_snapshot) do + ["Snapshot not migrated" | errors] + else + errors + end + + if errors == [] do + {:ok, "✅ Report verified (reason: #{pg_reason})"} + else + {:error, "❌ Report has mismatches", errors} + end + end + + def verify_preference(mongo_pref, pg_pref) do + errors = [] + + # Verify editor preferences are preserved + mongo_editor = mongo_pref["editor"] || %{} + pg_editor = pg_pref.editor || %{} + + errors = if is_map(mongo_editor) and map_size(mongo_editor) > 0 and map_size(pg_editor) == 0 do + ["Editor preferences not migrated" | errors] + else + errors + end + + # Check specific editor settings if both exist + if map_size(mongo_editor) > 0 and map_size(pg_editor) > 0 do + if mongo_editor["theme"] != pg_editor["theme"] do + errors = ["Theme mismatch: #{mongo_editor["theme"]} != #{pg_editor["theme"]}" | errors] + end + end + + if errors == [] do + {:ok, "✅ Preference verified"} + else + {:error, "❌ Preference has mismatches", errors} + end + end +end + +# Main verification logic +Logger.info("🔍 Starting Migration Verification") +Logger.info(String.duplicate("=", 60)) + +# Connect to MongoDB +mongo_uri = System.get_env("MONGO_URI") || raise "MONGO_URI environment variable required" +mongo_db = System.get_env("MONGO_DB_NAME") || "codincod-development" + +is_atlas = String.starts_with?(mongo_uri, "mongodb+srv://") + +connect_opts = [ + url: mongo_uri, + name: :mongo_verify, + database: mongo_db, + pool_size: 1 +] + +connect_opts = if is_atlas do + Keyword.merge(connect_opts, [ssl: true, ssl_opts: [verify: :verify_none]]) +else + connect_opts +end + +{:ok, conn} = Mongo.start_link(connect_opts) + +try do + total_verified = 0 + total_errors = 0 + + # 1. Verify Users + Logger.info("\n👤 Verifying Users...") + test_users = Mongo.find(conn, "users", %{"email" => %{"$regex" => "@migration-test.com"}}) |> Enum.to_list() + + user_results = Enum.map(test_users, fn mongo_user -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_user["_id"]) + pg_user = Repo.get_by(User, legacy_id: mongo_id) + + if pg_user do + MigrationVerifier.verify_user(mongo_user, pg_user) + else + {:error, "❌ User not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(user_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 2. Verify Puzzles + Logger.info("\n🧩 Verifying Puzzles...") + test_puzzles = Mongo.find(conn, "puzzles", %{"tags" => "test-migration"}) |> Enum.to_list() + + puzzle_results = Enum.map(test_puzzles, fn mongo_puzzle -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_puzzle["_id"]) + pg_puzzle = Repo.get_by(Puzzle, legacy_id: mongo_id) + + if pg_puzzle do + MigrationVerifier.verify_puzzle(mongo_puzzle, pg_puzzle, conn) + else + {:error, "❌ Puzzle not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(puzzle_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 3. Verify Submissions + Logger.info("\n📝 Verifying Submissions...") + test_submission_ids = Enum.map(1..10, fn i -> + MigrationVerifier.generate_test_object_id("test_submission_#{i}") + end) + + test_submissions = Mongo.find(conn, "submissions", %{"_id" => %{"$in" => test_submission_ids}}) |> Enum.to_list() + + submission_results = Enum.map(test_submissions, fn mongo_sub -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_sub["_id"]) + pg_sub = Repo.get_by(Submission, legacy_id: mongo_id) + + if pg_sub do + MigrationVerifier.verify_submission(mongo_sub, pg_sub) + else + {:error, "❌ Submission not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(submission_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 4. Verify Games + Logger.info("\n🎮 Verifying Games...") + test_game_ids = Enum.map(1..4, fn i -> + MigrationVerifier.generate_test_object_id("test_game_#{i}") + end) + + test_games = Mongo.find(conn, "games", %{"_id" => %{"$in" => test_game_ids}}) |> Enum.to_list() + + game_results = Enum.map(test_games, fn mongo_game -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_game["_id"]) + pg_game = Repo.get_by(Game, legacy_id: mongo_id) + + if pg_game do + MigrationVerifier.verify_game(mongo_game, pg_game) + else + {:error, "❌ Game not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(game_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 5. Verify Comments + Logger.info("\n💬 Verifying Comments...") + test_comment_ids = Enum.map(1..6, fn i -> + MigrationVerifier.generate_test_object_id("test_comment_#{i}") + end) + + test_comments = Mongo.find(conn, "comments", %{"_id" => %{"$in" => test_comment_ids}}) |> Enum.to_list() + + comment_results = Enum.map(test_comments, fn mongo_comment -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_comment["_id"]) + pg_comment = Repo.get_by(Comment, legacy_id: mongo_id) + + if pg_comment do + MigrationVerifier.verify_comment(mongo_comment, pg_comment) + else + {:error, "❌ Comment not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(comment_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 6. Verify Reports + Logger.info("\n🚩 Verifying Reports...") + test_report_ids = Enum.map(1..3, fn i -> + MigrationVerifier.generate_test_object_id("test_report_#{i}") + end) + + test_reports = Mongo.find(conn, "reports", %{"_id" => %{"$in" => test_report_ids}}) |> Enum.to_list() + + report_results = Enum.map(test_reports, fn mongo_report -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_report["_id"]) + pg_report = Repo.get_by(Report, legacy_id: mongo_id) + + if pg_report do + MigrationVerifier.verify_report(mongo_report, pg_report) + else + {:error, "❌ Report not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(report_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # 7. Verify Preferences + Logger.info("\n⚙️ Verifying Preferences...") + test_user_ids = Enum.map(1..5, fn i -> + MigrationVerifier.generate_test_object_id("test_user_#{i}") + end) + + test_preferences = Mongo.find(conn, "preferences", %{"owner" => %{"$in" => test_user_ids}}) |> Enum.to_list() + + pref_results = Enum.map(test_preferences, fn mongo_pref -> + mongo_id = MigrationVerifier.extract_mongo_id(mongo_pref["_id"]) + pg_pref = Repo.get_by(Preference, legacy_id: mongo_id) + + if pg_pref do + MigrationVerifier.verify_preference(mongo_pref, pg_pref) + else + {:error, "❌ Preference not found in PostgreSQL", ["Missing migration"]} + end + end) + + Enum.each(pref_results, fn + {:ok, msg} -> + Logger.info(" #{msg}") + total_verified = total_verified + 1 + {:error, msg, errors} -> + Logger.error(" #{msg}") + Enum.each(errors, &Logger.error(" - #{&1}")) + total_errors = total_errors + 1 + end) + + # Final Summary + Logger.info("\n" <> String.duplicate("=", 60)) + if total_errors == 0 do + Logger.info("✅ ALL VERIFICATIONS PASSED!") + Logger.info(" #{total_verified} objects verified successfully") + else + Logger.error("❌ VERIFICATION FAILED") + Logger.error(" #{total_verified} passed, #{total_errors} failed") + end + Logger.info(String.duplicate("=", 60)) + +after + GenServer.stop(conn) +end diff --git a/libs/backend/codincod_api/priv/repo/seeds.exs b/libs/backend/codincod_api/priv/repo/seeds.exs new file mode 100644 index 00000000..2e945373 --- /dev/null +++ b/libs/backend/codincod_api/priv/repo/seeds.exs @@ -0,0 +1,433 @@ +# Script for populating the database with test data +# +# Run with: mix run priv/repo/seeds.exs +# +# This will create test users, puzzles, and other data for development + +alias CodincodApi.Repo +alias CodincodApi.Accounts +alias CodincodApi.Accounts.User +alias CodincodApi.Puzzles +alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} +alias CodincodApi.Languages +alias CodincodApi.Languages.ProgrammingLanguage + +require Logger + +# Helper to safely insert or find existing record +defmodule SeedHelpers do + def insert_or_find(module, attrs, unique_field) do + case Repo.get_by(module, [{unique_field, Map.get(attrs, unique_field)}]) do + nil -> + %{module.__struct__() | id: Ecto.UUID.generate()} + |> module.changeset(attrs) + |> Repo.insert!() + + existing -> + Logger.info("#{module} with #{unique_field}=#{Map.get(attrs, unique_field)} already exists") + existing + end + end +end + +Logger.info("🌱 Starting seed process...") + +# ============================================================================ +# PROGRAMMING LANGUAGES +# ============================================================================ +Logger.info("Creating programming languages...") + +languages = [ + %{ + name: "python", + version: "3.12.0", + runtime: "python", + piston_name: "python" + }, + %{ + name: "javascript", + version: "18.15.0", + runtime: "node", + piston_name: "javascript" + }, + %{ + name: "ruby", + version: "3.2.0", + runtime: "ruby", + piston_name: "ruby" + }, + %{ + name: "rust", + version: "1.68.2", + runtime: "rust", + piston_name: "rust" + }, + %{ + name: "elixir", + version: "1.14.0", + runtime: "elixir", + piston_name: "elixir" + }, + %{ + name: "go", + version: "1.21.0", + runtime: "go", + piston_name: "go" + } +] + +_created_languages = + Enum.map(languages, fn lang_attrs -> + SeedHelpers.insert_or_find(ProgrammingLanguage, lang_attrs, :name) + end) + +# ============================================================================ +# TEST USERS +# ============================================================================ +Logger.info("Creating test users...") + +# Main test user (matches mongo_testdata.py) +codincoder = + SeedHelpers.insert_or_find( + User, + %{ + username: "codincoder", + email: "codincoder@example.com", + password: "strongpassword123!", + password_confirmation: "strongpassword123!", + profile: %{ + bio: "I love coding challenges!", + location: "Code City", + picture: nil, + socials: %{ + github: "codincoder", + twitter: "codincoder" + } + }, + role: "user" + }, + :username + ) + +# Additional test users for variety +alice = + SeedHelpers.insert_or_find( + User, + %{ + username: "alice", + email: "alice@example.com", + password: "alicepassword123!", + password_confirmation: "alicepassword123!", + profile: %{ + bio: "Algorithm enthusiast", + location: "Wonderland" + }, + role: "user" + }, + :username + ) + +bob = + SeedHelpers.insert_or_find( + User, + %{ + username: "bob", + email: "bob@example.com", + password: "bobpassword123!", + password_confirmation: "bobpassword123!", + profile: %{ + bio: "Puzzle solver extraordinaire" + }, + role: "user" + }, + :username + ) + +moderator = + SeedHelpers.insert_or_find( + User, + %{ + username: "moderator", + email: "moderator@example.com", + password: "modpassword123!", + password_confirmation: "modpassword123!", + profile: %{ + bio: "Keeping the platform safe" + }, + role: "moderator" + }, + :username + ) + +# ============================================================================ +# PUZZLES +# ============================================================================ +Logger.info("Creating test puzzles...") + +# Easy puzzle - Print 42 +easy_puzzle = + case Repo.get_by(Puzzle, title: "Print 42") do + nil -> + puzzle_attrs = %{ + title: "Print 42", + statement: "Print the number 42.", + constraints: "No input required", + difficulty: "BEGINNER", + visibility: "APPROVED", + tags: ["beginner", "output"], + solution: %{ + code: "print(42)", + language: "python", + languageVersion: "3.12.0" + }, + author_id: codincoder.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + # Add validators + validators = [ + %{input: "", output: "42"}, + %{input: "", output: "42"}, + %{input: "", output: "42"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Print 42' already exists") + existing + end + +# FizzBuzz puzzle (from mongo_testdata.py) +fizzbuzz_puzzle = + case Repo.get_by(Puzzle, title: "FizzBuzz") do + nil -> + puzzle_attrs = %{ + title: "FizzBuzz", + statement: """ + Print numbers from N to M except for: + - Every number divisible by 3: print "Fizz" + - Every number divisible by 5: print "Buzz" + - Numbers divisible by both 3 and 5: print "FizzBuzz" + + ## Input Format + Two space-separated integers: N and M + + ## Output Format + Print each result on a new line. + """, + constraints: "0 <= N < M <= 1000", + difficulty: "INTERMEDIATE", + visibility: "DRAFT", + tags: ["loops", "conditionals", "classic"], + solution: %{ + code: """ + n, m = [int(x) for x in input().split()] + for i in range(n, m+1): + fizz = i % 3 == 0 + buzz = i % 5 == 0 + print("Fizz" * fizz + "Buzz" * buzz + str(i) * (not fizz and not buzz)) + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: codincoder.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + # Add validators + validators = [ + %{ + input: "1 3", + output: "1\n2\nFizz" + }, + %{ + input: "3 5", + output: "Fizz\n4\nBuzz" + }, + %{ + input: "1 16", + output: "1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n16" + } + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'FizzBuzz' already exists") + existing + end + +# Reverse String puzzle +_reverse_puzzle = + case Repo.get_by(Puzzle, title: "Reverse String") do + nil -> + puzzle_attrs = %{ + title: "Reverse String", + statement: """ + Given a string, output it reversed. + + ## Input Format + A single line containing the string to reverse. + + ## Output Format + The reversed string. + """, + constraints: "1 <= string length <= 1000", + difficulty: "EASY", + visibility: "APPROVED", + tags: ["strings", "beginner"], + solution: %{ + code: "print(input()[::-1])", + language: "python", + languageVersion: "3.12.0" + }, + author_id: alice.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "hello", output: "olleh"}, + %{input: "world", output: "dlrow"}, + %{input: "racecar", output: "racecar"}, + %{input: "a", output: "a"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Reverse String' already exists") + existing + end + +# Sum of Numbers puzzle +_sum_puzzle = + case Repo.get_by(Puzzle, title: "Sum of Numbers") do + nil -> + puzzle_attrs = %{ + title: "Sum of Numbers", + statement: """ + Calculate the sum of all integers from 1 to N (inclusive). + + ## Input Format + A single integer N. + + ## Output Format + The sum of integers from 1 to N. + """, + constraints: "1 <= N <= 10000", + difficulty: "BEGINNER", + visibility: "APPROVED", + tags: ["math", "beginner", "loops"], + solution: %{ + code: """ + n = int(input()) + print(sum(range(1, n + 1))) + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: bob.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "1", output: "1"}, + %{input: "5", output: "15"}, + %{input: "10", output: "55"}, + %{input: "100", output: "5050"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Sum of Numbers' already exists") + existing + end + +# Palindrome Check puzzle +_palindrome_puzzle = + case Repo.get_by(Puzzle, title: "Palindrome Check") do + nil -> + puzzle_attrs = %{ + title: "Palindrome Check", + statement: """ + Determine if a given string is a palindrome. + + A palindrome reads the same forwards and backwards (ignoring case). + + ## Input Format + A single string. + + ## Output Format + Print "YES" if it's a palindrome, "NO" otherwise. + """, + constraints: "1 <= string length <= 1000", + difficulty: "EASY", + visibility: "APPROVED", + tags: ["strings", "palindrome"], + solution: %{ + code: """ + s = input().strip().lower() + print("YES" if s == s[::-1] else "NO") + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: alice.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "racecar", output: "YES"}, + %{input: "hello", output: "NO"}, + %{input: "A man a plan a canal Panama", output: "NO"}, + %{input: "aabbaa", output: "YES"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Palindrome Check' already exists") + existing + end + +Logger.info("✅ Seed data created successfully!") +Logger.info(" Users: codincoder, alice, bob, moderator") +Logger.info(" Puzzles: 5 puzzles with validators") +Logger.info(" Programming Languages: 6 languages") diff --git a/libs/backend/codincod_api/priv/static/favicon.ico b/libs/backend/codincod_api/priv/static/favicon.ico new file mode 100644 index 00000000..7f372bfc Binary files /dev/null and b/libs/backend/codincod_api/priv/static/favicon.ico differ diff --git a/libs/backend/codincod_api/priv/static/robots.txt b/libs/backend/codincod_api/priv/static/robots.txt new file mode 100644 index 00000000..26e06b5f --- /dev/null +++ b/libs/backend/codincod_api/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/libs/backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs new file mode 100644 index 00000000..afd407f1 --- /dev/null +++ b/libs/backend/codincod_api/test/codincod_api_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule CodincodApiWeb.ErrorJSONTest do + use CodincodApiWeb.ConnCase, async: true + + test "renders 404" do + assert CodincodApiWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert CodincodApiWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/libs/backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs new file mode 100644 index 00000000..28ed8194 --- /dev/null +++ b/libs/backend/codincod_api/test/codincod_api_web/controllers/submission_controller_test.exs @@ -0,0 +1,207 @@ +defmodule CodincodApiWeb.SubmissionControllerTest do + use CodincodApiWeb.ConnCase, async: true + + import Ecto.Changeset + + alias CodincodApi.Accounts.{Password, User} + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} + alias CodincodApi.Submissions.Submission + alias CodincodApi.Repo + + @valid_password "Sup3rSecurePass!" + + setup %{conn: conn} do + user = insert_user!(%{username: "submitter"}) + {:ok, token, _claims} = CodincodApiWeb.Auth.Guardian.generate_token(user) + + authed_conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer #{token}") + + on_exit(fn -> + Application.delete_env(:codincod_api, :piston_mock_execute) + end) + + {:ok, conn: authed_conn, user: user} + end + + describe "POST /api/v1/submission" do + test "creates a submission and returns result summary", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "puzzle-author"}) + puzzle = insert_puzzle!(author, %{title: "Echo Puzzle"}) + insert_validator!(puzzle, %{input: "hello", output: "hello"}) + + body = %{ + "puzzleId" => puzzle.id, + "programmingLanguageId" => language.id, + "code" => "print('hello')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(201) + + assert response["puzzleId"] == puzzle.id + assert response["programmingLanguageId"] == language.id + assert response["userId"] == user.id + assert response["codeLength"] == String.length(body["code"]) + + assert %{"successRate" => 1.0, "passed" => 1, "failed" => 0, "total" => 1} = + response["result"] + + submission = Repo.get!(Submission, response["submissionId"]) + assert submission.result["result"] == "success" + assert submission.result["successRate"] == 1.0 + end + + test "returns 404 when puzzle is missing", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + missing_id = Ecto.UUID.generate() + + body = %{ + "puzzleId" => missing_id, + "programmingLanguageId" => language.id, + "code" => "print('oops')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(404) + + assert response["error"] == "Puzzle not found" + end + + test "returns 400 when puzzle lacks validators", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "no-validators"}) + puzzle = insert_puzzle!(author, %{title: "Incomplete Puzzle"}) + + body = %{ + "puzzleId" => puzzle.id, + "programmingLanguageId" => language.id, + "code" => "print('test')", + "userId" => user.id + } + + response = + conn + |> post(~p"/api/v1/submission", body) + |> json_response(400) + + assert response["error"] == "Failed to update the puzzle" + end + end + + describe "GET /api/v1/submission/:id" do + test "returns submission details", %{conn: conn, user: user} do + language = insert_language!(%{language: "python", version: "3.10.0", runtime: "python"}) + author = insert_user!(%{username: "puzzle-owner"}) + puzzle = insert_puzzle!(author, %{title: "Stored Puzzle"}) + + submission = + %Submission{} + |> change(%{ + user_id: user.id, + puzzle_id: puzzle.id, + programming_language_id: language.id, + code: "print('stored')", + result: %{ + "result" => "success", + "successRate" => 1.0, + "passed" => 1, + "failed" => 0, + "total" => 1 + } + }) + |> Repo.insert!() + |> Repo.preload([:programming_language, :puzzle, :user]) + + response = + conn + |> get(~p"/api/v1/submission/#{submission.id}") + |> json_response(200) + + assert response["id"] == submission.id + assert response["code"] == submission.code + assert response["programmingLanguage"]["id"] == submission.programming_language_id + assert response["user"]["id"] == user.id + end + end + + defp insert_user!(attrs) do + base_attrs = %{ + username: "user" <> unique_suffix(), + email: unique_email(), + profile: %{}, + role: "user" + } + + attrs = Map.merge(base_attrs, attrs) + + {:ok, password_hash} = Password.hash(@valid_password) + + %User{} + |> change(%{ + username: attrs.username, + email: attrs.email, + profile: attrs.profile, + role: attrs.role, + password_hash: password_hash + }) + |> Repo.insert!() + end + + defp insert_language!(attrs) do + %ProgrammingLanguage{} + |> change(%{ + language: Map.get(attrs, :language, "python"), + version: Map.get(attrs, :version, "3.10.0"), + runtime: Map.get(attrs, :runtime, "python"), + aliases: Map.get(attrs, :aliases, []), + is_active: Map.get(attrs, :is_active, true) + }) + |> Repo.insert!() + end + + defp insert_puzzle!(%User{id: author_id}, attrs) do + %Puzzle{} + |> change(%{ + title: Map.get(attrs, :title, "Puzzle #{unique_suffix()}"), + statement: Map.get(attrs, :statement, "Solve me"), + constraints: Map.get(attrs, :constraints, nil), + author_id: author_id, + difficulty: Map.get(attrs, :difficulty, "BEGINNER"), + visibility: Map.get(attrs, :visibility, "APPROVED"), + tags: [], + solution: %{} + }) + |> Repo.insert!() + end + + defp insert_validator!(%Puzzle{id: puzzle_id}, attrs) do + %PuzzleValidator{} + |> change(%{ + puzzle_id: puzzle_id, + input: Map.get(attrs, :input, ""), + output: Map.get(attrs, :output, ""), + is_public: Map.get(attrs, :is_public, false) + }) + |> Repo.insert!() + end + + defp unique_suffix do + System.unique_integer([:positive]) |> Integer.to_string() + end + + defp unique_email do + "user-" <> unique_suffix() <> "@example.com" + end +end diff --git a/libs/backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs b/libs/backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs new file mode 100644 index 00000000..392a55b2 --- /dev/null +++ b/libs/backend/codincod_api/test/codincod_api_web/controllers/user_controller_test.exs @@ -0,0 +1,171 @@ +defmodule CodincodApiWeb.UserControllerTest do + use CodincodApiWeb.ConnCase, async: true + + import Ecto.Changeset + + alias CodincodApi.Accounts.{Password, User} + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Repo + + @valid_password "Sup3rSecurePass!" + + describe "GET /api/v1/user/:username" do + test "returns user details", %{conn: conn} do + user = insert_user!(%{username: "ada", email: "ada@example.com"}) + + response = + conn + |> get(~p"/api/v1/user/#{user.username}") + |> json_response(200) + + assert response["message"] == "User found" + assert response["user"]["id"] == user.id + assert response["user"]["username"] == user.username + end + + test "returns 404 when user missing", %{conn: conn} do + response = + conn + |> get(~p"/api/v1/user/missing-user") + |> json_response(404) + + assert response["message"] == "User not found" + end + end + + describe "GET /api/v1/user/:username/isAvailable" do + test "reports username availability", %{conn: conn} do + insert_user!(%{username: "lovelace"}) + + response = + conn + |> get(~p"/api/v1/user/lovelace/isAvailable") + |> json_response(200) + + refute response["available"] + + response = + conn + |> get(~p"/api/v1/user/newhandle/isAvailable") + |> json_response(200) + + assert response["available"] + end + end + + describe "GET /api/v1/user/:username/puzzle" do + test "paginates public puzzles for non-owners", %{conn: conn} do + author = insert_user!(%{username: "puzzler"}) + + insert_puzzle!(author, %{title: "Approved Puzzle", visibility: "APPROVED"}) + insert_puzzle!(author, %{title: "Draft Puzzle", visibility: "DRAFT"}) + + response = + conn + |> get(~p"/api/v1/user/#{author.username}/puzzle", %{page: 1, pageSize: 10}) + |> json_response(200) + + assert response["totalItems"] == 1 + [puzzle] = response["items"] + assert puzzle["title"] == "Approved Puzzle" + assert puzzle["visibility"] == "approved" + + # Ensure hitting the /api route remains compatible + response_legacy = + conn + |> get(~p"/api/user/#{author.username}/puzzle", %{page: 1, pageSize: 10}) + |> json_response(200) + + assert response_legacy["totalItems"] == 1 + end + end + + describe "GET /api/v1/user/:username/activity" do + test "returns public activity", %{conn: conn} do + author = insert_user!(%{username: "activity"}) + language = insert_language!(%{language: "elixir", version: "1.16"}) + puzzle = insert_puzzle!(author, %{title: "Activity Puzzle", visibility: "APPROVED"}) + + insert_submission!(author, puzzle, language) + + response = + conn + |> get(~p"/api/v1/user/#{author.username}/activity") + |> json_response(200) + + assert response["message"] == "User activity found" + assert length(response["activity"]["puzzles"]) == 1 + assert length(response["activity"]["submissions"]) == 1 + end + end + + defp insert_user!(attrs) do + base_attrs = %{ + username: "tester" <> unique_suffix(), + email: unique_email(), + profile: %{}, + role: "user" + } + + attrs = Map.merge(base_attrs, attrs) + + {:ok, password_hash} = Password.hash(@valid_password) + + %User{} + |> change(%{ + username: attrs.username, + email: attrs.email, + profile: attrs.profile, + role: attrs.role, + password_hash: password_hash + }) + |> Repo.insert!() + end + + defp insert_puzzle!(%User{id: author_id}, attrs) do + %Puzzle{} + |> change(%{ + title: Map.get(attrs, :title, "Sample Puzzle #{unique_suffix()}"), + statement: "Solve it!", + constraints: nil, + author_id: author_id, + difficulty: Map.get(attrs, :difficulty, "BEGINNER"), + visibility: Map.get(attrs, :visibility, "APPROVED"), + tags: [], + solution: %{} + }) + |> Repo.insert!() + end + + defp insert_language!(attrs) do + %CodincodApi.Languages.ProgrammingLanguage{} + |> change(%{ + language: Map.get(attrs, :language, "elixir"), + version: Map.get(attrs, :version, "1.16"), + aliases: [], + runtime: Map.get(attrs, :runtime, "elixir"), + is_active: true + }) + |> Repo.insert!() + end + + defp insert_submission!(%User{id: user_id}, %Puzzle{id: puzzle_id}, language) do + %CodincodApi.Submissions.Submission{} + |> change(%{ + user_id: user_id, + puzzle_id: puzzle_id, + programming_language_id: language.id, + code: "IO.puts(:hello)", + result: %{"status" => "success"} + }) + |> Repo.insert!() + end + + defp unique_suffix do + System.unique_integer([:positive]) |> Integer.to_string() + end + + defp unique_email do + "user-" <> unique_suffix() <> "@example.com" + end +end diff --git a/libs/backend/codincod_api/test/support/conn_case.ex b/libs/backend/codincod_api/test/support/conn_case.ex new file mode 100644 index 00000000..3f1faa27 --- /dev/null +++ b/libs/backend/codincod_api/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule CodincodApiWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use CodincodApiWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint CodincodApiWeb.Endpoint + + use CodincodApiWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import CodincodApiWeb.ConnCase + end + end + + setup tags do + CodincodApi.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/libs/backend/codincod_api/test/support/data_case.ex b/libs/backend/codincod_api/test/support/data_case.ex new file mode 100644 index 00000000..34f06c13 --- /dev/null +++ b/libs/backend/codincod_api/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule CodincodApi.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use CodincodApi.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias CodincodApi.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import CodincodApi.DataCase + end + end + + setup tags do + CodincodApi.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(CodincodApi.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/libs/backend/codincod_api/test/test_helper.exs b/libs/backend/codincod_api/test/test_helper.exs new file mode 100644 index 00000000..fa96ea11 --- /dev/null +++ b/libs/backend/codincod_api/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(CodincodApi.Repo, :manual) diff --git a/libs/backend/codincod_api/test_migration.sh b/libs/backend/codincod_api/test_migration.sh new file mode 100644 index 00000000..b26d5eec --- /dev/null +++ b/libs/backend/codincod_api/test_migration.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Master test script for MongoDB to PostgreSQL migration +# This script: +# 1. Seeds MongoDB with test data +# 2. Runs the migration +# 3. Verifies the migrated data +# +# Usage: +# ./test_migration.sh + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} MongoDB → PostgreSQL Migration Test${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check environment variables +if [ -z "$MONGO_URI" ]; then + echo -e "${RED}ERROR: MONGO_URI environment variable not set${NC}" + echo "Please set it to your MongoDB connection string:" + echo " export MONGO_URI='mongodb+srv://...'" + exit 1 +fi + +if [ -z "$MONGO_DB_NAME" ]; then + echo -e "${YELLOW}WARNING: MONGO_DB_NAME not set, using default: codincod-development${NC}" + export MONGO_DB_NAME="codincod-development" +fi + +echo -e "${GREEN}✓${NC} Environment variables set" +echo " MONGO_DB: $MONGO_DB_NAME" +echo "" + +# Step 1: Seed test data +echo -e "${BLUE}Step 1/3: Seeding MongoDB with test data...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix run priv/repo/scripts/seed_test_data_mongodb.exs +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Failed to seed test data${NC}" + exit 1 +fi +echo "" + +# Step 2: Run migration +echo -e "${BLUE}Step 2/3: Running migration...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix migrate_mongo +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Migration failed${NC}" + exit 1 +fi +echo "" + +# Step 3: Verify migration +echo -e "${BLUE}Step 3/3: Verifying migrated data...${NC}" +echo -e "${YELLOW}----------------------------------------${NC}" +mix run priv/repo/scripts/verify_migration.exs +if [ $? -ne 0 ]; then + echo -e "${RED}✗ Verification failed${NC}" + exit 1 +fi +echo "" + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} ✓ Migration Test Complete!${NC}" +echo -e "${GREEN}========================================${NC}" diff --git a/libs/backend/complete_migration.exs b/libs/backend/complete_migration.exs new file mode 100644 index 00000000..095436bb --- /dev/null +++ b/libs/backend/complete_migration.exs @@ -0,0 +1,435 @@ +#!/usr/bin/env elixir + +# CodinCod Elixir Backend - Complete Migration Script +# This script guides you through completing the entire backend migration + +Mix.start() + +IO.puts """ +╔══════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ CodinCod Backend Migration - Completion Guide ║ +║ ║ +║ TypeScript → Elixir Migration ║ +║ MongoDB → PostgreSQL Migration ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════════╝ + +This guide will walk you through completing the backend migration. +Follow the steps carefully and run each command in sequence. + +""" + +defmodule MigrationGuide do + def step(number, title) do + IO.puts "\n┌────────────────────────────────────────────────────────────────────┐" + IO.puts "│ STEP #{number}: #{title}" + IO.puts "└────────────────────────────────────────────────────────────────────┘\n" + end + + def command(cmd, description) do + IO.puts " #{description}" + IO.puts " $ #{cmd}\n" + end + + def info(msg) do + IO.puts " ℹ️ #{msg}\n" + end + + def warn(msg) do + IO.ANSI.format([:yellow, " ⚠️ #{msg}\n"]) |> IO.write() + end + + def success(msg) do + IO.ANSI.format([:green, " ✅ #{msg}\n"]) |> IO.write() + end + + def pause() do + IO.gets("\n Press ENTER to continue...") + end +end + +import MigrationGuide + +step(1, "Fix bcrypt Compilation (Windows)") +info("Choose ONE of the following options:") +IO.puts """ + Option A: Use WSL2 (RECOMMENDED) + $ wsl --install + $ wsl + $ cd /mnt/c/Users/ReevenGovaert/Documents/projects/CodinCod/libs/elixir-backend/codincod_api + + Option B: Install Visual Studio Build Tools + Download from: https://visualstudio.microsoft.com/downloads/ + Install "Desktop development with C++" + + Option C: Use Docker + $ docker build -t codincod-api . + + Option D: Switch to pbkdf2 (dev only) + Replace bcrypt_elixir with pbkdf2_elixir in mix.exs +""" +pause() + +step(2, "Install Dependencies & Compile") +command("mix deps.get", "Download all dependencies") +command("mix deps.compile", "Compile dependencies") +command("mix compile", "Compile application") +pause() + +step(3, "Start PostgreSQL Database") +command("docker-compose up -d postgres redis", "Start database services") +command("docker-compose ps", "Verify services are running") +pause() + +step(4, "Create Database") +command("mix ecto.create", "Create development database") +command("mix ecto.create MIX_ENV=test", "Create test database") +success("Databases created!") +pause() + +step(5, "Generate Authentication System") +warn("This command will ask questions. Answer as follows:") +IO.puts """ + An authentication system can be created in two different ways: + - Using Phoenix.LiveView (default) + - Using Phoenix.Controller only + + CHOOSE: No (we want API-only) + + An accounts context already exists... + CHOOSE: No (or Yes to overwrite) +""" +command("mix phx.gen.auth Accounts User users --binary-id", "Generate auth system") +pause() + +step(6, "Customize User Schema") +info("Edit: lib/codincod_api/accounts/user.ex") +IO.puts """ + Add these fields to the schema block: + + field :profile_avatar_url, :string + field :profile_bio, :text + field :profile_location, :string + field :role, Ecto.Enum, values: [:user, :admin, :moderator], default: :user + field :report_count, :integer, default: 0 + field :ban_count, :integer, default: 0 + field :legacy_mongo_id, :string + belongs_to :current_ban, CodincodApi.Moderation.UserBan +""" +pause() + +step(7, "Create UserBan Schema") +command( + "mix phx.gen.schema Moderation.UserBan user_bans user_id:references:users banned_by_id:references:users reason:text ban_type:string expires_at:utc_datetime --binary-id", + "Create moderation schema" +) +pause() + +step(8, "Create ProgrammingLanguage Schema") +command( + "mix phx.gen.context Languages ProgrammingLanguage programming_languages name:string version:string piston_runtime:string is_active:boolean --binary-id", + "Create language context" +) +pause() + +step(9, "Create Puzzle Context & Schema") +command( + ~s(mix phx.gen.context Puzzles Puzzle puzzles title:string statement:text constraints:text difficulty:string visibility:string author_id:references:users --binary-id), + "Create puzzle context" +) +pause() + +step(10, "Create Submission Context & Schema") +command( + "mix phx.gen.context Submissions Submission submissions code:text result:map user_id:references:users puzzle_id:references:puzzles programming_language_id:references:programming_languages --binary-id", + "Create submission context" +) +pause() + +step(11, "Create Game Context & Schema") +command( + "mix phx.gen.context Games Game games owner_id:references:users puzzle_id:references:puzzles start_time:utc_datetime end_time:utc_datetime options:map --binary-id", + "Create game context" +) +pause() + +step(12, "Create GamePlayer Join Table") +command( + "mix phx.gen.schema Games.GamePlayer game_players game_id:references:games user_id:references:users joined_at:utc_datetime submission_id:references:submissions --binary-id", + "Create game player schema" +) +pause() + +step(13, "Create Comment Context & Schema") +command( + "mix phx.gen.context Comments Comment comments author_id:references:users text:text upvote:integer downvote:integer comment_type:string parent_id:references:comments --binary-id", + "Create comment context" +) +pause() + +step(14, "Create ChatMessage Schema") +command( + "mix phx.gen.context Chat ChatMessage chat_messages game_id:references:games user_id:references:users message:text is_deleted:boolean --binary-id", + "Create chat context" +) +pause() + +step(15, "Create UserVote Schema") +command( + "mix phx.gen.schema Comments.UserVote user_votes user_id:references:users comment_id:references:comments vote_type:string --binary-id", + "Create user vote schema" +) +pause() + +step(16, "Create Report Schema") +command( + "mix phx.gen.context Moderation Report reports reporter_id:references:users reported_user_id:references:users reason:text status:string resolved_by_id:references:users --binary-id", + "Create report context" +) +pause() + +step(17, "Create UserMetrics Schema") +command( + "mix phx.gen.schema Metrics.UserMetrics user_metrics user_id:references:users puzzles_solved:integer puzzles_attempted:integer total_submissions:integer rating:integer rank:integer --binary-id", + "Create metrics schema" +) +pause() + +step(18, "Run All Migrations") +command("mix ecto.migrate", "Apply all database migrations") +success("Database schema created!") +pause() + +step(19, "Create Authentication Controllers") +info("Create: lib/codincod_api_web/controllers/auth_controller.ex") +IO.puts """ + Implement endpoints: + - register/2 + - login/2 + - logout/2 + - refresh/2 + - current_user/2 +""" +pause() + +step(20, "Create Routes") +info("Edit: lib/codincod_api_web/router.ex") +IO.puts """ + Add routes: + + scope "/api", CodincodApiWeb do + pipe_through :api + + # Public routes + post "/register", AuthController, :register + post "/login", AuthController, :login + + # Protected routes + pipe_through :auth + post "/logout", AuthController, :logout + get "/user", AuthController, :current_user + # ... more routes + end +""" +pause() + +step(21, "Create Phoenix Channels") +command("mkdir -p lib/codincod_api_web/channels", "Create channels directory") +info("Create WaitingRoomChannel and GameChannel") +pause() + +step(22, "Implement Piston Client") +info("Create: lib/codincod_api/piston/client.ex") +IO.puts """ + Use Tesla/Finch to communicate with Piston API: + + defmodule CodincodApi.Piston.Client do + use Tesla + + plug Tesla.Middleware.BaseUrl, Application.get_env(:codincod_api, :piston)[:base_url] + plug Tesla.Middleware.JSON + + def execute(code, language, version) do + post("/execute", %{ + language: language, + version: version, + files: [%{content: code}] + }) + end + end +""" +pause() + +step(23, "Create Oban Workers") +info("Create background job workers:") +IO.puts """ + - lib/codincod_api/workers/execute_submission.ex + - lib/codincod_api/workers/update_statistics.ex + - lib/codincod_api/workers/recalculate_leaderboard.ex + - lib/codincod_api/workers/send_email.ex +""" +pause() + +step(24, "Implement Data Migration") +info("Create: lib/codincod_api/data_migration.ex") +IO.puts """ + Implement functions: + - migrate_all/0 + - migrate_users/0 + - migrate_puzzles/0 + - migrate_submissions/0 + - migrate_games/0 + - validate_migration/0 +""" +pause() + +step(25, "Create Mix Tasks") +info("Create migration mix tasks:") +command("touch lib/mix/tasks/migrate_mongo.ex", "Create migration task") +command("touch lib/mix/tasks/gen_typescript_types.ex", "Create type gen task") +pause() + +step(26, "Implement TypeScript Type Generator") +info("Generate TypeScript types from Ecto schemas for frontend") +pause() + +step(27, "Write Tests") +info("Create comprehensive tests:") +IO.puts """ + Unit Tests: + - test/codincod_api/accounts_test.exs + - test/codincod_api/puzzles_test.exs + - test/codincod_api/submissions_test.exs + - test/codincod_api/games_test.exs + + Integration Tests: + - test/codincod_api_web/controllers/*_test.exs + + Channel Tests: + - test/codincod_api_web/channels/*_test.exs +""" +pause() + +step(28, "Configure CORS & Security") +info("Edit: lib/codincod_api_web/endpoint.ex") +IO.puts """ + Add CORS plug: + + plug CORSPlug, + origin: ~r/^https?:\/\/(localhost:5173|codincod\.com)$/, + credentials: true +""" +pause() + +step(29, "Add Rate Limiting") +info("Create rate limiting plugs") +pause() + +step(30, "Create Health Check Endpoint") +command("mix phx.gen.json Health Check checks --no-context --no-schema", "Generate health controller") +pause() + +step(31, "Seed Database") +info("Edit: priv/repo/seeds.exs") +IO.puts """ + Create seed data: + - Admin user + - Sample users (10-20) + - Programming languages + - Sample puzzles (20-30) + - Sample submissions +""" +command("mix run priv/repo/seeds.exs", "Run seeds") +pause() + +step(32, "Run Data Migration") +warn("Ensure MongoDB is running with legacy data") +command("mix migrate_mongo", "Migrate data from MongoDB") +command("mix migrate_mongo --validate", "Validate migration") +pause() + +step(33, "Generate TypeScript Types") +command("mix gen_typescript_types", "Generate TypeScript types for frontend") +pause() + +step(34, "Run All Tests") +command("mix test", "Run test suite") +command("mix test --cover", "Check test coverage") +pause() + +step(35, "Performance Testing") +info("Test performance with:") +IO.puts """ + - Load testing tools (wrk, Apache Bench) + - Concurrent WebSocket connections + - Database query optimization + - N+1 query detection +""" +pause() + +step(36, "Production Configuration") +info("Configure for production:") +IO.puts """ + - Set environment variables + - Configure SSL + - Set up error tracking (Sentry) + - Configure CDN + - Set up monitoring +""" +pause() + +step(37, "Documentation") +info("Update documentation:") +IO.puts """ + - API documentation (OpenAPI/Swagger) + - WebSocket event documentation + - Deployment guide + - Runbook for operations +""" +pause() + +step(38, "Deployment") +info("Deploy to production:") +IO.puts """ + Option A: Docker + $ docker build -t codincod-api:latest . + $ docker push codincod-api:latest + + Option B: Mix Release + $ MIX_ENV=prod mix release + $ _build/prod/rel/codincod_api/bin/codincod_api start + + Option C: Fly.io / Gigalixir / Render + Follow platform-specific deployment guides +""" +pause() + +success("Migration Steps Complete!") + +IO.puts """ + +╔══════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ 🎉 Migration Guide Complete! 🎉 ║ +║ ║ +║ You have completed all the steps for migrating the CodinCod backend ║ +║ from TypeScript/MongoDB to Elixir/PostgreSQL. ║ +║ ║ +║ Next Steps: ║ +║ 1. Verify all tests pass ║ +║ 2. Performance test the application ║ +║ 3. Update frontend to use new backend ║ +║ 4. Deploy to staging environment ║ +║ 5. Monitor and optimize ║ +║ ║ +╚══════════════════════════════════════════════════════════════════════════╝ + +For detailed information, see: +- MIGRATION_GUIDE.md - Comprehensive migration guide +- README.md - Project documentation +- STATUS.md - Current status and next steps +- WINDOWS_SETUP.md - Windows-specific setup + +Good luck with your migration! 🚀 +""" diff --git a/libs/backend/docker-compose.yml b/libs/backend/docker-compose.yml index d027cb46..5eaae194 100644 --- a/libs/backend/docker-compose.yml +++ b/libs/backend/docker-compose.yml @@ -1,10 +1,124 @@ services: - mongo: + postgres: + image: postgres:16-alpine + container_name: codincod_postgres_dev + environment: + POSTGRES_DB: ${POSTGRES_DB:-codincod_dev} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - codincod_network + + piston: + image: ghcr.io/engineer-man/piston + container_name: codincod_piston + restart: unless-stopped + privileged: true + ports: + - "${PISTON_PORT:-2000}:2000" + volumes: + - ../data/piston/packages:/piston/packages + tmpfs: + - /tmp:exec + networks: + - codincod_network + + postgres_test: + image: postgres:16-alpine + container_name: codincod_postgres_test + environment: + POSTGRES_DB: codincod_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5433:5432" + tmpfs: + - /var/lib/postgresql/data + networks: + - codincod_network + + # MongoDB for migration period + mongodb: image: mongo - restart: always + container_name: codincod_mongodb environment: - MONGO_INITDB_ROOT_USERNAME: "${CODINCOD_MONGODB_USERNAME}" - MONGO_INITDB_ROOT_PASSWORD: "${CODINCOD_MONGODB_PASSWORD}" - MONGO_INITDB_DATABASE: codincod + MONGO_INITDB_DATABASE: ${MONGO_DB_NAME:-codincod-development} + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME:-codincod-dev} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-hunter2} ports: - "27017:27017" + volumes: + - mongodb_data:/data/db + networks: + - codincod_network + + # Redis for caching and rate limiting + redis: + image: redis:8-alpine + container_name: codincod_redis + ports: + - "${REDIS_PORT:-6379}:6379" + command: redis-server --requirepass ${REDIS_PASSWORD:-redis_password} + volumes: + - redis_data:/data + networks: + - codincod_network + + api: + build: + context: ./codincod_api + dockerfile: Dockerfile + container_name: codincod_elixir_api + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + piston: + condition: service_started + environment: + MIX_ENV: dev + PHX_SERVER: "true" + PHX_PORT: ${PHX_PORT:-4000} + POSTGRES_HOST: postgres + POSTGRES_DB: ${POSTGRES_DB:-codincod_dev} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + PISTON_URI: http://piston:2000 + REDIS_HOST: redis + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_password} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev_secret} + JWT_ISSUER: ${JWT_ISSUER:-codincod_api} + command: sh -c "mix deps.get && mix ecto.create && mix ecto.migrate && mix phx.server" + ports: + - "${PHX_PORT:-4000}:4000" + volumes: + - ./codincod_api:/app + - codincod_deps:/app/deps + - codincod_build:/app/_build + networks: + - codincod_network + +volumes: + postgres_data: + mongodb_data: + redis_data: + codincod_deps: + codincod_build: + +networks: + codincod_network: + driver: bridge diff --git a/libs/backend/eslint.config.js b/libs/backend/eslint.config.js deleted file mode 100644 index 582528d9..00000000 --- a/libs/backend/eslint.config.js +++ /dev/null @@ -1,39 +0,0 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import eslintConfigPrettier from "eslint-config-prettier"; -import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; - -export default [ - { - languageOptions: { - globals: globals.node - } - }, - pluginJs.configs.recommended, - eslintConfigPrettier, - { - ignores: [ - "**/node_modules", - "**/dist", - "**/build", - "**/__snapshots__", - "**/mocks", - "**/coverage" - ] - }, - { - plugins: { - "eslint-plugin-sort-destructure-keys": sortDestructureKeys - }, - rules: { - "no-undef": "warn", - "no-unused-vars": "warn", - "sort-keys": [ - "error", - "asc", - { caseSensitive: true, minKeys: 2, natural: false } - ], - yoda: "error" - } - } -]; diff --git a/libs/backend/init.sql b/libs/backend/init.sql new file mode 100644 index 00000000..4aadf218 --- /dev/null +++ b/libs/backend/init.sql @@ -0,0 +1,18 @@ +-- PostgreSQL initialization script +-- This script runs when the PostgreSQL container is first created + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; +CREATE EXTENSION IF NOT EXISTS "btree_gin"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Create custom types if needed +-- (Will be created by Ecto migrations) + +-- Log successful initialization +DO $$ +BEGIN + RAISE NOTICE 'PostgreSQL extensions initialized successfully'; +END $$; diff --git a/libs/backend/migrate.sh b/libs/backend/migrate.sh new file mode 100644 index 00000000..ee47c761 --- /dev/null +++ b/libs/backend/migrate.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# CodinCod Elixir Backend Migration Script +# This script continues the migration from TypeScript/MongoDB to Elixir/PostgreSQL + +set -e + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT/codincod_api" + +echo "🚀 Starting CodinCod Elixir Backend Migration..." + +# Step 1: Compile dependencies +echo "📦 Compiling dependencies..." +mix deps.compile + +# Step 2: Generate authentication system +echo "🔐 Generating authentication system with phx.gen.auth..." +mix phx.gen.auth Accounts User users --binary-id --no-prompt || true + +# Step 3: Create database +echo "🗄️ Creating database..." +mix ecto.create + +# Step 4: Run migrations +echo "⬆️ Running migrations..." +mix ecto.migrate + +echo "✅ Basic migration setup complete!" +echo "" +echo "Next steps:" +echo "1. Review generated authentication code in lib/codincod_api/accounts/" +echo "2. Customize User schema with additional fields (profile, roles, bans)" +echo "3. Create remaining schemas (Puzzle, Submission, Game, etc.)" +echo "4. Implement WebSocket channels for real-time features" +echo "5. Create data migration scripts from MongoDB" +echo "6. Implement TypeScript type generation" +echo "" +echo "📚 Documentation: See MIGRATION_GUIDE.md for detailed steps" diff --git a/libs/backend/package.json b/libs/backend/package.json deleted file mode 100644 index 6788bd5e..00000000 --- a/libs/backend/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "backend", - "version": "0.0.1", - "main": "dist/index.js", - "type": "module", - "types": "dist/index.d.ts", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "pkgroll", - "start": "tsx src/index.ts", - "lint": "npx eslint .", - "lint:fix": "npm run lint -- --fix", - "prettier": "npx prettier . --check", - "prettier:fix": "npm run prettier -- --write", - "format": "npm run prettier:fix && npm run lint:fix", - "test": "vitest", - "seed": "tsx src/seeds/index.ts", - "seed:force": "tsx src/seeds/index.ts --force", - "seed:clear": "tsx src/seeds/clear.ts", - "seed:clear:force": "tsx src/seeds/clear.ts --force", - "migrate": "tsx src/migrations/migrate.ts run", - "migrate:list": "tsx src/migrations/migrate.ts list", - "migrate:rollback": "tsx src/migrations/migrate.ts rollback" - }, - "author": "", - "license": "ISC", - "description": "", - "dependencies": { - "@fastify/cookie": "^9.3.1", - "@fastify/cors": "^9.0.1", - "@fastify/formbody": "^7.4.0", - "@fastify/jwt": "^8.0.1", - "@fastify/rate-limit": "9.1.0", - "@fastify/websocket": "^10.0.1", - "@types/node-cron": "^3.0.11", - "bcryptjs": "^3.0.2", - "dotenv": "^16.4.5", - "fastify": "^4.28.1", - "fastify-plugin": "^4.5.1", - "mongodb": "^6.8.0", - "mongoose": "^8.5.1", - "node-cron": "^4.2.1", - "zod": "^4.1.12" - }, - "devDependencies": { - "@eslint/js": "^9.7.0", - "@faker-js/faker": "^10.1.0", - "@tsconfig/recommended": "^1.0.7", - "@types/bcrypt": "^5.0.2", - "@types/node": "^24.0.1", - "@types/ws": "^8.5.12", - "eslint": "9.x", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-sort-destructure-keys": "^2.0.0", - "globals": "^16.2.0", - "pkgroll": "^2.4.1", - "prettier": "3.3.3", - "tsx": "^4.16.2", - "types": "workspace:*", - "typescript": "^5.5.4", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.8" - }, - "lint-staged": { - "**/*": "prettier --write --ignore-unknown" - } -} diff --git a/libs/backend/src/app.ts b/libs/backend/src/app.ts deleted file mode 100644 index 57509563..00000000 --- a/libs/backend/src/app.ts +++ /dev/null @@ -1,82 +0,0 @@ -// require external modules -import dotenv from "dotenv"; -dotenv.config(); - -import websocket from "@fastify/websocket"; -import Fastify from "fastify"; -import cors from "./plugins/config/cors.js"; -import jwt from "./plugins/config/jwt.js"; -import fastifyFormbody from "@fastify/formbody"; -import mongooseConnector from "./plugins/config/mongoose.js"; -import router from "./router.js"; -import fastifyCookie, { FastifyCookieOptions } from "@fastify/cookie"; -import piston from "./plugins/decorators/piston.js"; -import { setupWebSockets } from "./plugins/config/setup-web-sockets.js"; -import fastifyRateLimit from "@fastify/rate-limit"; -import requestLogger from "./plugins/middleware/request-logger.js"; -import { httpResponseCodes } from "types"; -import { initializeLeaderboardCron } from "./config/cron.js"; - -const server = Fastify({ - logger: true -}); - -// register fastify ecosystem plugins -server.register(fastifyCookie, { - secret: process.env.COOKIE_SECRET -} as FastifyCookieOptions); -server.register(requestLogger); -server.register(fastifyRateLimit, { - max: 100, - timeWindow: "1 minute", - errorResponseBuilder: (request, context) => { - return { - statusCode: httpResponseCodes.CLIENT_ERROR.TOO_MANY_REQUESTS, - error: "Too Many Requests", - message: `Rate limit exceeded. Please try again in ${Math.ceil(context.ttl / 1000)} seconds.`, - retryAfter: Math.ceil(context.ttl / 1000) - }; - } -}); -server.register(cors); -server.register(jwt); -server.register(fastifyFormbody); -server.register(mongooseConnector); -server.register(piston); -server.register(websocket, { - options: { - verifyClient: (info, next) => { - // Allow WebSocket connections from the configured frontend URL - const origin = info.origin || info.req.headers.origin; - const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173"; - - server.log.info( - { origin, allowedOrigin }, - "WebSocket connection attempt" - ); - - // Allow if origin matches exactly OR if no origin is provided (some clients don't send it) - if (!origin || origin === allowedOrigin) { - server.log.info({ origin }, "WebSocket connection accepted"); - next(true); - } else { - server.log.warn( - { origin, allowedOrigin }, - "WebSocket connection rejected" - ); - next(false, 403, "Forbidden"); - } - } - } -}); -server.register(setupWebSockets); - -// routes -server.register(router); - -// Initialize cron jobs after all plugins are registered -server.ready(() => { - initializeLeaderboardCron(server); -}); - -export default server; diff --git a/libs/backend/src/config/cron.ts b/libs/backend/src/config/cron.ts deleted file mode 100644 index 67a7c0c9..00000000 --- a/libs/backend/src/config/cron.ts +++ /dev/null @@ -1,47 +0,0 @@ -import cron from "node-cron"; -import { leaderboardService } from "../services/leaderboard.service.js"; -import { FastifyInstance } from "fastify"; - -/** - * Initialize cron jobs for leaderboard calculations - * Runs every hour on the hour - */ -export function initializeLeaderboardCron(fastify: FastifyInstance): void { - // Run every hour at minute 0 - // Cron format: minute hour day month weekday - const cronExpression = "0 * * * *"; // Every hour at :00 - - cron.schedule(cronExpression, async () => { - fastify.log.info("Starting hourly leaderboard recalculation..."); - - try { - const startTime = Date.now(); - const results = await leaderboardService.recalculateAllLeaderboards(); - const duration = Date.now() - startTime; - - fastify.log.info( - { - processedGames: results.processedGames, - totalProcessed: results.totalProcessed, - durationMs: duration - }, - "Leaderboard recalculation completed" - ); - } catch (error) { - fastify.log.error( - { - err: error - }, - "Error during leaderboard recalculation" - ); - } - }); - - fastify.log.info( - { - schedule: cronExpression, - description: "Hourly leaderboard recalculation" - }, - "Leaderboard cron job initialized" - ); -} diff --git a/libs/backend/src/config/generic-return-messages.ts b/libs/backend/src/config/generic-return-messages.ts deleted file mode 100644 index 10667d42..00000000 --- a/libs/backend/src/config/generic-return-messages.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { httpResponseCodes } from "types"; - -export const genericReturnMessages = { - [httpResponseCodes.CLIENT_ERROR.BAD_REQUEST]: { - IS_INVALID: "is invalid", - CONTAINS_INVALID_DATA: "contains invalid data" - }, - [httpResponseCodes.SUCCESSFUL.OK]: { - WAS_FOUND: "was found" - }, - [httpResponseCodes.CLIENT_ERROR.NOT_FOUND]: { - COULD_NOT_BE_FOUND: "couldn't be found" - }, - [httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR]: { - WENT_WRONG: "went wrong" - } -} as const; - -export const userProperties = { - USERNAME: "username" -} as const; diff --git a/libs/backend/src/index.ts b/libs/backend/src/index.ts deleted file mode 100644 index 8c699d83..00000000 --- a/libs/backend/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import server from "./app.js"; - -const FASTIFY_HOST = process.env.FASTIFY_HOST ?? "0.0.0.0"; -const FASTIFY_PORT = Number(process.env.FASTIFY_PORT) || 8888; - -// Debug: Log environment variables at startup -console.log("=== Environment Configuration ==="); -console.log("NODE_ENV:", process.env.NODE_ENV); -console.log("FRONTEND_URL:", process.env.FRONTEND_URL); -console.log("================================"); - -// start server -server.listen({ port: FASTIFY_PORT, host: FASTIFY_HOST }, (err, address) => { - if (err) { - server.log.error(err); - process.exit(1); - } - - console.log(`🚀 Fastify server running on port ${address}`); -}); diff --git a/libs/backend/src/migrations/framework/migration-runner.ts b/libs/backend/src/migrations/framework/migration-runner.ts deleted file mode 100644 index e6cd140b..00000000 --- a/libs/backend/src/migrations/framework/migration-runner.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Migration } from "./migration.interface.js"; -import { migrationStatus, MigrationTracker } from "./migration-tracker.js"; - -/** - * Migration runner that executes migrations in order - */ -export class MigrationRunner { - private migrations: Migration[] = []; - - /** - * Register a migration to be run - */ - register(migration: Migration): void { - this.migrations.push(migration); - } - - /** - * Run all pending migrations - */ - async runAll(): Promise { - console.log("🔄 Checking for pending migrations...\n"); - - // Sort migrations by name (which should be date-prefixed) - this.migrations.sort((a, b) => a.name.localeCompare(b.name)); - - // Get already applied migrations - const appliedMigrations = await MigrationTracker.find({ - status: migrationStatus.APPLIED - }).lean(); - const appliedNames = new Set(appliedMigrations.map((m) => m.name)); - - // Filter to pending migrations - const pendingMigrations = this.migrations.filter( - (m) => !appliedNames.has(m.name) - ); - - if (pendingMigrations.length === 0) { - console.log("✅ No pending migrations. Database is up to date.\n"); - return; - } - - console.log(`Found ${pendingMigrations.length} pending migration(s):\n`); - pendingMigrations.forEach((m, i) => { - console.log(` ${i + 1}. ${m.name}`); - console.log(` ${m.description}\n`); - }); - - // Run each pending migration - for (const migration of pendingMigrations) { - await this.runOne(migration); - } - - console.log("\n✨ All migrations completed successfully!\n"); - } - - /** - * Run a specific migration - */ - async runOne(migration: Migration): Promise { - console.log(`\n${"=".repeat(60)}`); - console.log(`Running migration: ${migration.name}`); - console.log(`Description: ${migration.description}`); - console.log("=".repeat(60)); - - const startTime = Date.now(); - - try { - // Run the migration - await migration.up(); - - // Record success - await MigrationTracker.create({ - name: migration.name, - description: migration.description, - appliedAt: new Date(), - status: migrationStatus.APPLIED - }); - - const duration = ((Date.now() - startTime) / 1000).toFixed(2); - console.log(`\n✅ Migration completed successfully in ${duration}s`); - } catch (error) { - // Record failure - await MigrationTracker.create({ - name: migration.name, - description: migration.description, - appliedAt: new Date(), - status: migrationStatus.FAILED, - error: error instanceof Error ? error.message : String(error) - }); - - console.error(`\n❌ Migration failed:`, error); - throw error; - } - } - - /** - * Rollback the last applied migration - */ - async rollbackLast(): Promise { - const lastMigration = await MigrationTracker.findOne({ - status: migrationStatus.APPLIED - }) - .sort({ appliedAt: -1 }) - .lean(); - - if (!lastMigration) { - console.log("No migrations to rollback."); - return; - } - - const migration = this.migrations.find( - (m) => m.name === lastMigration.name - ); - - if (!migration) { - throw new Error( - `Migration ${lastMigration.name} not found in registered migrations` - ); - } - - if (!migration.down) { - throw new Error(`Migration ${migration.name} does not support rollback`); - } - - console.log(`\nRolling back migration: ${migration.name}\n`); - - try { - await migration.down(); - - await MigrationTracker.findOneAndUpdate( - { name: migration.name }, - { - rollbackAt: new Date(), - status: migrationStatus.ROLLED_BACK - } - ); - - console.log(`✅ Rollback completed successfully`); - } catch (error) { - console.error(`❌ Rollback failed:`, error); - throw error; - } - } - - /** - * List all migrations and their status - */ - async list(): Promise { - const applied = await MigrationTracker.find().sort({ appliedAt: 1 }).lean(); - - console.log("\n📋 Migration Status:\n"); - - // Sort all migrations by name - const sortedMigrations = [...this.migrations].sort((a, b) => - a.name.localeCompare(b.name) - ); - - for (const migration of sortedMigrations) { - const record = applied.find((m) => m.name === migration.name); - - if (record) { - const status = - record.status === migrationStatus.APPLIED - ? "✅ Applied" - : record.status === migrationStatus.ROLLED_BACK - ? "⏪ Rolled back" - : "❌ Failed"; - const date = record.appliedAt.toISOString().split("T")[0]; - console.log(` ${status} - ${migration.name} (${date})`); - } else { - console.log(` ⏳ Pending - ${migration.name}`); - } - console.log(` ${migration.description}\n`); - } - } -} diff --git a/libs/backend/src/migrations/framework/migration-tracker.ts b/libs/backend/src/migrations/framework/migration-tracker.ts deleted file mode 100644 index f6c427ba..00000000 --- a/libs/backend/src/migrations/framework/migration-tracker.ts +++ /dev/null @@ -1,55 +0,0 @@ -import mongoose, { Schema, Document } from "mongoose"; -import { ValueOf } from "types"; - -export const migrationStatus = { - APPLIED: "applied", - ROLLED_BACK: "rolled-back", - FAILED: "failed" -} as const; -export type MigrationStatus = ValueOf; - -export interface MigrationRecord extends Document { - name: string; - description: string; - appliedAt: Date; - rollbackAt?: Date; - status: MigrationStatus; - error?: string; -} - -const migrationRecordSchema = new Schema({ - name: { - type: String, - required: true, - unique: true, - index: true - }, - description: { - type: String, - required: true - }, - appliedAt: { - type: Date, - required: true, - default: Date.now - }, - rollbackAt: { - type: Date, - required: false - }, - status: { - type: String, - enum: Object.values(migrationStatus), - required: true, - default: migrationStatus.APPLIED - }, - error: { - type: String, - required: false - } -}); - -export const MigrationTracker = mongoose.model( - "MigrationTracker", - migrationRecordSchema -); diff --git a/libs/backend/src/migrations/framework/migration.interface.ts b/libs/backend/src/migrations/framework/migration.interface.ts deleted file mode 100644 index 18c3d53c..00000000 --- a/libs/backend/src/migrations/framework/migration.interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Interface for all database migrations - * Each migration must implement this interface to be run by the migration system - */ -export interface Migration { - /** - * Unique name/identifier for this migration - * Format: YYYY-MM-DD-descriptive-name - * Example: "2025-10-26-add-programming-language-entity" - */ - name: string; - - /** - * Description of what this migration does - */ - description: string; - - /** - * Execute the migration - * This should be idempotent - safe to run multiple times - */ - up(): Promise; - - /** - * Rollback the migration (optional) - * If not implemented, rollback is not supported for this migration - */ - down?(): Promise; -} diff --git a/libs/backend/src/migrations/migrate-to-programming-language.ts b/libs/backend/src/migrations/migrate-to-programming-language.ts deleted file mode 100644 index 09f5155b..00000000 --- a/libs/backend/src/migrations/migrate-to-programming-language.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { config } from "dotenv"; -config(); - -import { - connectToDatabase, - disconnectFromDatabase -} from "../seeds/utils/db-connection.js"; -import ProgrammingLanguage from "../models/programming-language/language.js"; -import Submission from "../models/submission/submission.js"; -import Puzzle from "../models/puzzle/puzzle.js"; -import Game from "../models/game/game.js"; -import { - arePistonRuntimes, - httpRequestMethod, - isString, - PistonRuntime, - pistonUrls -} from "types"; -import { buildPistonUri } from "@/utils/functions/build-piston-uri.js"; - -interface OldSubmission { - _id: any; - language?: string; - languageVersion?: string; - programmingLanguage?: string; -} - -interface OldPuzzleSolution { - language?: string; - languageVersion?: string; - programmingLanguage?: string; - code?: string; - explanation?: string; -} - -interface OldPuzzle { - _id: any; - solution?: OldPuzzleSolution; -} - -interface OldGameLanguage { - language: string; - version: string; - aliases?: string[]; - runtime?: string; -} - -interface OldGame { - _id: any; - options?: { - allowedLanguages?: (string | OldGameLanguage)[]; - }; -} - -/** - * Migration script to: - * 1. Fetch programming languages from Piston - * 2. Create ProgrammingLanguage documents - * 3. Migrate existing data (Submissions, Puzzles/Solutions, Games) to reference ProgrammingLanguage ObjectIds - */ -async function migrate() { - console.log("🔄 Starting migration to ProgrammingLanguage entity...\n"); - console.log("=".repeat(50)); - - try { - await connectToDatabase(); - - // Step 1: Fetch Piston runtimes - console.log("\n📡 Fetching available runtimes from Piston..."); - const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), { - method: httpRequestMethod.GET, - headers: { - "Content-Type": "application/json" - } - }); - const runtimes = await response.json(); - - if (!arePistonRuntimes(runtimes)) { - throw new Error("Failed to fetch valid Piston runtimes"); - } - - console.log(`✓ Found ${runtimes.length} runtimes from Piston`); - - // Step 2: Create ProgrammingLanguage documents - console.log("\n🗄️ Creating ProgrammingLanguage documents..."); - - // Check if ProgrammingLanguages already exist - const existingCount = await ProgrammingLanguage.countDocuments({}); - if (existingCount > 0) { - console.log( - ` ⚠️ Found ${existingCount} existing programming languages` - ); - console.log(" ℹ️ Skipping recreation to preserve existing references"); - console.log( - " 💡 If you need to re-seed languages, clear the database first" - ); - } else { - // Insert all runtimes as programming languages - const languageDocs = runtimes.map((runtime: PistonRuntime) => ({ - language: runtime.language, - version: runtime.version, - aliases: runtime.aliases || [], - runtime: runtime.runtime - })); - - const insertedLanguages = - await ProgrammingLanguage.insertMany(languageDocs); - console.log( - ` ✓ Created ${insertedLanguages.length} programming languages` - ); - } - - // Build language map from current database state - const allLanguages = await ProgrammingLanguage.find({}); - console.log( - ` ✓ Loaded ${allLanguages.length} programming languages for migration` - ); - - // Create a map for quick lookup: "language:version" -> ObjectId - const languageMap = new Map(); - allLanguages.forEach((lang) => { - const key = `${lang.language}:${lang.version}`; - languageMap.set(key, lang._id.toString()); - }); - - // Step 3: Migrate Submissions - console.log("\n📝 Migrating Submissions..."); - const submissions = (await Submission.find({ - language: { $exists: true }, - languageVersion: { $exists: true } - }).lean()) as unknown as OldSubmission[]; - console.log(` Found ${submissions.length} submissions to migrate`); - - let submissionsMigrated = 0; - let submissionsCreated = 0; - for (const submission of submissions) { - if (!submission.language || !submission.languageVersion) { - console.warn( - ` ⚠️ Skipping submission ${submission._id} - missing language data` - ); - continue; - } - - const key = `${submission.language}:${submission.languageVersion}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: submission.language, - version: submission.languageVersion, - aliases: [] - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - submissionsCreated++; - } - - await Submission.findByIdAndUpdate(submission._id, { - $set: { programmingLanguage: languageId }, - $unset: { language: "", languageVersion: "" } - }); - submissionsMigrated++; - } - console.log( - ` ✓ Migrated ${submissionsMigrated} submissions (${submissionsCreated} languages created)` - ); - - // Step 4: Migrate Puzzle Solutions - console.log("\n🧩 Migrating Puzzle Solutions..."); - - // First, check total puzzles - const totalPuzzles = await Puzzle.countDocuments({}); - console.log(` Total puzzles in database: ${totalPuzzles}`); - - // Check puzzles with solution field - const puzzlesWithSolution = await Puzzle.countDocuments({ - solution: { $exists: true, $ne: null } - }); - console.log(` Puzzles with solution field: ${puzzlesWithSolution}`); - - // Find puzzles with old language fields - const puzzles = (await Puzzle.find({ - "solution.language": { $exists: true } - }) - .select("+solution") - .lean()) as unknown as OldPuzzle[]; - console.log( - ` Found ${puzzles.length} puzzles with solution.language to migrate` - ); - - let solutionsMigrated = 0; - let solutionsCreated = 0; - let solutionsSkipped = 0; - - for (const puzzle of puzzles) { - if (!puzzle.solution?.language || !puzzle.solution?.languageVersion) { - console.log( - ` ⚠️ Skipping puzzle ${puzzle._id} - missing language: ${puzzle.solution?.language}, version: ${puzzle.solution?.languageVersion}` - ); - solutionsSkipped++; - continue; - } - - const key = `${puzzle.solution.language}:${puzzle.solution.languageVersion}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: puzzle.solution.language, - version: puzzle.solution.languageVersion, - aliases: [] - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - solutionsCreated++; - } - - await Puzzle.findByIdAndUpdate(puzzle._id, { - $set: { "solution.programmingLanguage": languageId }, - $unset: { "solution.language": "", "solution.languageVersion": "" } - }); - solutionsMigrated++; - } - console.log( - ` ✓ Migrated ${solutionsMigrated} puzzle solutions (${solutionsCreated} languages created, ${solutionsSkipped} skipped)` - ); - - // Step 5: Migrate Game allowedLanguages - console.log("\n🎮 Migrating Game allowedLanguages..."); - const games = (await Game.find({ - "options.allowedLanguages": { $exists: true, $ne: [] } - }).lean()) as unknown as OldGame[]; - console.log(` Found ${games.length} games to migrate`); - - let gamesMigrated = 0; - let gamesCreated = 0; - let gamesSkipped = 0; - - for (const game of games) { - if ( - !game.options?.allowedLanguages || - game.options.allowedLanguages.length === 0 - ) { - continue; - } - - // Check if already migrated (first element is a string ObjectId) - const firstLang = game.options.allowedLanguages[0]; - if (isString(firstLang)) { - // Already migrated, skip - console.log(` ⏭️ Game ${game._id} already migrated, skipping`); - gamesSkipped++; - continue; - } - - const allowedLanguageIds: string[] = []; - for (const allowedLang of game.options.allowedLanguages) { - if (isString(allowedLang)) { - // Already an ObjectId, keep it - allowedLanguageIds.push(allowedLang); - continue; - } - - // Skip if language or version is missing - if (!allowedLang.language || !allowedLang.version) { - console.warn( - ` ⚠️ Skipping invalid language in game ${game._id}: language=${allowedLang.language}, version=${allowedLang.version}` - ); - continue; - } - - const key = `${allowedLang.language}:${allowedLang.version}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: allowedLang.language, - version: allowedLang.version, - aliases: allowedLang.aliases || [], - runtime: allowedLang.runtime - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - gamesCreated++; - } - - allowedLanguageIds.push(languageId); - } - - if (allowedLanguageIds.length > 0) { - await Game.findByIdAndUpdate(game._id, { - $set: { "options.allowedLanguages": allowedLanguageIds } - }); - gamesMigrated++; - } - } - console.log( - ` ✓ Migrated ${gamesMigrated} games (${gamesCreated} languages created, ${gamesSkipped} skipped)` - ); - - console.log("\n" + "=".repeat(50)); - console.log("✨ Migration completed successfully!\n"); - console.log("Summary:"); - console.log(` - Programming Languages: ${allLanguages.length}`); - console.log(` - Submissions migrated: ${submissionsMigrated}`); - console.log(` - Solutions migrated: ${solutionsMigrated}`); - console.log(` - Games migrated: ${gamesMigrated}`); - console.log( - "\n⚠️ IMPORTANT: Update your schemas and models before deploying!" - ); - } catch (error) { - console.error("\n❌ Migration failed:", error); - process.exit(1); - } finally { - await disconnectFromDatabase(); - } -} - -migrate(); diff --git a/libs/backend/src/migrations/migrate.ts b/libs/backend/src/migrations/migrate.ts deleted file mode 100644 index 0c26cb5b..00000000 --- a/libs/backend/src/migrations/migrate.ts +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env node -import { config } from "dotenv"; -config(); - -import { - connectToDatabase, - disconnectFromDatabase -} from "../seeds/utils/db-connection.js"; -import { MigrationRunner } from "./framework/migration-runner.js"; -import { AddProgrammingLanguageEntityMigration } from "./migrations/2025-10-26-add-programming-language-entity.js"; -import { MigrateUserRolesToRoleMigration } from "./migrations/2025-10-26-migrate-user-roles-to-role.js"; - -/** - * Main migration CLI tool - * - * Usage: - * pnpm migrate - Run all pending migrations - * pnpm migrate:list - List all migrations and their status - * pnpm migrate:rollback - Rollback the last migration - */ -async function main() { - const command = process.argv[2] || "run"; - - console.log("🔧 CodinCod Migration Tool\n"); - - try { - // Connect to database - await connectToDatabase(); - - // Create migration runner - const runner = new MigrationRunner(); - - // Register all migrations here (in chronological order) - runner.register(new MigrateUserRolesToRoleMigration()); - runner.register(new AddProgrammingLanguageEntityMigration()); - - // Execute command - switch (command) { - case "run": - case "up": - await runner.runAll(); - break; - - case "list": - case "status": - await runner.list(); - break; - - case "rollback": - case "down": - await runner.rollbackLast(); - break; - - default: - console.error(`Unknown command: ${command}`); - console.log("\nAvailable commands:"); - console.log(" run, up - Run all pending migrations"); - console.log(" list, status - List all migrations and their status"); - console.log(" rollback, down - Rollback the last migration"); - process.exit(1); - } - } catch (error) { - console.error("\n❌ Migration failed:", error); - process.exit(1); - } finally { - await disconnectFromDatabase(); - } -} - -main(); diff --git a/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts b/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts deleted file mode 100644 index ad682086..00000000 --- a/libs/backend/src/migrations/migrations/2025-10-26-add-programming-language-entity.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { Migration } from "../framework/migration.interface.js"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; -import Submission from "../../models/submission/submission.js"; -import Puzzle from "../../models/puzzle/puzzle.js"; -import Game from "../../models/game/game.js"; -import { - arePistonRuntimes, - httpRequestMethod, - isObjectId, - isString, - PistonRuntime, - pistonUrls -} from "types"; -import { buildPistonUri } from "../../utils/functions/build-piston-uri.js"; - -interface OldSubmission { - _id: any; - language?: string; - languageVersion?: string; - programmingLanguage?: string; -} - -interface OldPuzzleSolution { - language?: string; - languageVersion?: string; - programmingLanguage?: string; - code?: string; - explanation?: string; -} - -interface OldPuzzle { - _id: any; - solution?: OldPuzzleSolution; -} - -interface OldGameLanguage { - language: string; - version: string; - aliases?: string[]; - runtime?: string; -} - -interface OldGame { - _id: any; - options?: { - allowedLanguages?: (string | OldGameLanguage)[]; - }; -} - -/** - * Migration: Add ProgrammingLanguage entity and migrate existing data - * - * This migration: - * 1. Fetches programming languages from Piston - * 2. Creates ProgrammingLanguage documents - * 3. Migrates existing Submissions to reference ProgrammingLanguage ObjectIds - * 4. Migrates existing Puzzle Solutions to reference ProgrammingLanguage ObjectIds - * 5. Migrates existing Game allowedLanguages to reference ProgrammingLanguage ObjectIds - */ -export class AddProgrammingLanguageEntityMigration implements Migration { - name = "2025-10-26-add-programming-language-entity"; - description = - "Create ProgrammingLanguage collection and migrate all language references from embedded strings to ObjectId references"; - - async up(): Promise { - // Step 1: Fetch Piston runtimes - console.log("\n📡 Fetching available runtimes from Piston..."); - const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), { - method: httpRequestMethod.GET, - headers: { - "Content-Type": "application/json" - } - }); - const runtimes = await response.json(); - - if (!arePistonRuntimes(runtimes)) { - throw new Error("Failed to fetch valid Piston runtimes"); - } - - console.log(` ✓ Found ${runtimes.length} runtimes from Piston`); - - // Step 2: Create ProgrammingLanguage documents - console.log("\n🗄️ Creating ProgrammingLanguage documents..."); - - // Check if languages already exist - const existingCount = await ProgrammingLanguage.countDocuments(); - if (existingCount > 0) { - console.log( - ` ℹ️ Found ${existingCount} existing languages, skipping creation` - ); - } else { - // Insert all runtimes as programming languages - const languageDocs = runtimes.map((runtime: PistonRuntime) => ({ - language: runtime.language, - version: runtime.version, - aliases: runtime.aliases || [], - runtime: runtime.runtime - })); - - const insertedLanguages = - await ProgrammingLanguage.insertMany(languageDocs); - console.log( - ` ✓ Created ${insertedLanguages.length} programming languages` - ); - } - - // Get all languages for mapping - const allLanguages = await ProgrammingLanguage.find().lean(); - - // Create a map for quick lookup: "language:version" -> ObjectId - const languageMap = new Map(); - allLanguages.forEach((lang) => { - const key = `${lang.language}:${lang.version}`; - languageMap.set(key, lang._id.toString()); - }); - - // Step 3: Migrate Submissions - await this.migrateSubmissions(languageMap); - - // Step 4: Migrate Puzzle Solutions - await this.migratePuzzleSolutions(languageMap); - - // Step 5: Migrate Game allowedLanguages - await this.migrateGameLanguages(languageMap); - } - - private async migrateSubmissions( - languageMap: Map - ): Promise { - console.log("\n📝 Migrating Submissions..."); - - // Find submissions that still have the old fields - const submissions = (await Submission.find({ - language: { $exists: true }, - languageVersion: { $exists: true } - }).lean()) as unknown as OldSubmission[]; - - console.log(` Found ${submissions.length} submissions to migrate`); - - let migrated = 0; - let skipped = 0; - let created = 0; - - for (const submission of submissions) { - if (!submission.language || !submission.languageVersion) { - skipped++; - continue; - } - - const key = `${submission.language}:${submission.languageVersion}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: submission.language, - version: submission.languageVersion, - aliases: [] - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - created++; - } - - await Submission.findByIdAndUpdate(submission._id, { - $set: { programmingLanguage: languageId }, - $unset: { language: "", languageVersion: "" } - }); - migrated++; - } - - console.log( - ` ✓ Migrated ${migrated} submissions (${skipped} skipped, ${created} languages created)` - ); - } - - private async migratePuzzleSolutions( - languageMap: Map - ): Promise { - console.log("\n🧩 Migrating Puzzle Solutions..."); - - // Find puzzles with solutions that have old fields - const puzzles = (await Puzzle.find({ - "solution.language": { $exists: true } - }).lean()) as unknown as OldPuzzle[]; - - console.log(` Found ${puzzles.length} puzzles with solutions to migrate`); - - let migrated = 0; - let skipped = 0; - let created = 0; - - for (const puzzle of puzzles) { - if (!puzzle.solution?.language || !puzzle.solution?.languageVersion) { - skipped++; - continue; - } - - const key = `${puzzle.solution.language}:${puzzle.solution.languageVersion}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: puzzle.solution.language, - version: puzzle.solution.languageVersion, - aliases: [] - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - created++; - } - - await Puzzle.findByIdAndUpdate(puzzle._id, { - $set: { "solution.programmingLanguage": languageId }, - $unset: { "solution.language": "", "solution.languageVersion": "" } - }); - migrated++; - } - - console.log( - ` ✓ Migrated ${migrated} puzzle solutions (${skipped} skipped, ${created} languages created)` - ); - } - - private async migrateGameLanguages( - languageMap: Map - ): Promise { - console.log("\n🎮 Migrating Game allowedLanguages..."); - - // Find games that might have old-style allowedLanguages - const games = (await Game.find({ - "options.allowedLanguages": { $exists: true, $ne: [] } - }).lean()) as unknown as OldGame[]; - - console.log(` Found ${games.length} games to check`); - - let migrated = 0; - let skipped = 0; - let created = 0; - - for (const game of games) { - if ( - !game.options?.allowedLanguages || - game.options.allowedLanguages.length === 0 - ) { - skipped++; - continue; - } - - // Check if first element is a string (ObjectId) or object - const firstLang = game.options.allowedLanguages[0]; - if (isString(firstLang)) { - // Already migrated - skipped++; - continue; - } - - const allowedLanguageIds: string[] = []; - for (const allowedLang of game.options.allowedLanguages) { - if (isObjectId(allowedLang)) { - allowedLanguageIds.push(allowedLang); - continue; - } - - const key = `${allowedLang.language}:${allowedLang.version}`; - let languageId = languageMap.get(key); - - // If language doesn't exist, create it - if (!languageId) { - console.log(` 📝 Creating missing language: ${key}`); - const newLanguage = await ProgrammingLanguage.create({ - language: allowedLang.language, - version: allowedLang.version, - aliases: allowedLang.aliases || [], - runtime: allowedLang.runtime - }); - languageId = newLanguage._id.toString(); - languageMap.set(key, languageId); - created++; - } - - allowedLanguageIds.push(languageId); - } - - if (allowedLanguageIds.length > 0) { - await Game.findByIdAndUpdate(game._id, { - $set: { "options.allowedLanguages": allowedLanguageIds } - }); - migrated++; - } else { - skipped++; - } - } - - console.log( - ` ✓ Migrated ${migrated} games (${skipped} skipped, ${created} languages created)` - ); - } - - async down(): Promise { - throw new Error( - "Rollback not supported for programming language migration. " + - "Original data is removed during migration. " + - "Please restore from backup if rollback is needed." - ); - } -} diff --git a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts b/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts deleted file mode 100644 index 3995dbf3..00000000 --- a/libs/backend/src/migrations/migrations/2025-10-26-migrate-user-roles-to-role.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Migration } from "../framework/migration.interface.js"; -import User from "../../models/user/user.js"; -import { DEFAULT_USER_ROLE } from "types"; - -interface OldUser { - _id: any; - roles?: string[]; - role?: string; -} - -/** - * Migration: Convert user roles array to single role - * - * This migration: - * 1. Finds users with the old 'roles' array field - * 2. Takes the first role from the array (or uses default if empty) - * 3. Sets it as the new 'role' string field - * 4. Removes the old 'roles' array field - */ -export class MigrateUserRolesToRoleMigration implements Migration { - name = "2025-10-26-migrate-user-roles-to-role"; - description = - "Migrate user 'roles' array field to singular 'role' string field"; - - async up(): Promise { - console.log("\n👥 Migrating User roles to role..."); - - // Find users that still have the old 'roles' array field - const users = (await User.find({ - roles: { $exists: true } - }).lean()) as unknown as OldUser[]; - - console.log(` Found ${users.length} users to migrate`); - - let migrated = 0; - let skipped = 0; - - for (const user of users) { - if (!user.roles || !Array.isArray(user.roles)) { - skipped++; - continue; - } - - const newRole = user.roles.length > 0 ? user.roles[0] : DEFAULT_USER_ROLE; - - await User.findByIdAndUpdate(user._id, { - $set: { role: newRole }, - $unset: { roles: "" } - }); - - migrated++; - } - - console.log(` ✓ Migrated ${migrated} users (${skipped} skipped)`); - } - - async down(): Promise { - console.log("\n👥 Rolling back User role to roles..."); - - // Find users with the singular 'role' field - const users = await User.find({ - role: { $exists: true } - }).lean(); - - console.log(` Found ${users.length} users to rollback`); - - let rolledBack = 0; - - for (const user of users) { - const currentRole = user.role; - - if (!currentRole) { - continue; - } - - // Convert singular role to array - await User.findByIdAndUpdate(user._id, { - $set: { roles: [currentRole] }, - $unset: { role: "" } - }); - - rolledBack++; - } - - console.log(` ✓ Rolled back ${rolledBack} users`); - } -} diff --git a/libs/backend/src/models/chat/chat-message.ts b/libs/backend/src/models/chat/chat-message.ts deleted file mode 100644 index 27b99c7c..00000000 --- a/libs/backend/src/models/chat/chat-message.ts +++ /dev/null @@ -1,55 +0,0 @@ -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { ChatMessageEntity } from "types"; -import { CHAT_MESSAGE, GAME, USER } from "../../utils/constants/model.js"; - -export interface ChatMessageDocument - extends Document, - Omit { - gameId: ObjectId; - userId: ObjectId; -} - -const chatMessageSchema = new Schema({ - gameId: { - type: Schema.Types.ObjectId, - ref: GAME, - required: true - }, - userId: { - type: Schema.Types.ObjectId, - ref: USER, - required: true - }, - username: { - type: String, - required: true - }, - message: { - type: String, - required: true - }, - isDeleted: { - type: Boolean, - default: false - }, - createdAt: { - type: Date, - default: Date.now - }, - updatedAt: { - type: Date, - default: Date.now - } -}); - -// Indexes for efficient querying -chatMessageSchema.index({ gameId: 1, createdAt: -1 }); -chatMessageSchema.index({ userId: 1 }); -chatMessageSchema.index({ createdAt: 1 }); - -const ChatMessage = mongoose.model( - CHAT_MESSAGE, - chatMessageSchema -); - -export default ChatMessage; diff --git a/libs/backend/src/models/comment/comment.ts b/libs/backend/src/models/comment/comment.ts deleted file mode 100644 index c303ff30..00000000 --- a/libs/backend/src/models/comment/comment.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { COMMENT, USER } from "@/utils/constants/model.js"; -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { commentTypeEnum, type CommentEntity } from "types"; - -export interface CommentDocument - extends Document, - Omit { - _id: ObjectId; - author: ObjectId; - parentId: ObjectId; -} - -export const commentSchema = new Schema({ - author: { - ref: USER, - type: Schema.Types.ObjectId, - required: true - }, - downvote: { - type: Number, - required: true, - default: 0 - }, - upvote: { - type: Number, - required: true, - default: 0 - }, - text: { - type: String, - required: true - }, - createdAt: { - default: Date.now, - type: Date - }, - updatedAt: { - default: Date.now, - type: Date - }, - comments: [ - { - type: Schema.Types.ObjectId, - ref: COMMENT - } - ], - commentType: { - type: String, - required: true, - default: commentTypeEnum.COMMENT - }, - parentId: { - type: Schema.Types.ObjectId, - ref: COMMENT, - required: false - } -}); - -commentSchema.pre( - "deleteOne", - { document: true, query: false }, - async function (next) { - await Comment.deleteMany({ _id: { $in: this.comments } }); - - next(); - } -); - -const Comment = mongoose.model(COMMENT, commentSchema); -export default Comment; diff --git a/libs/backend/src/models/game/game-config.ts b/libs/backend/src/models/game/game-config.ts deleted file mode 100644 index 171740d4..00000000 --- a/libs/backend/src/models/game/game-config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import mongoose, { Schema } from "mongoose"; -import { GameOptions } from "types"; -import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js"; - -/** - * Game options schema - * Defines configuration for game sessions including allowed languages, duration, visibility, and mode - */ -const gameOptionsSchema = new Schema({ - maxGameDurationInSeconds: { - required: true, - type: Number - }, - visibility: { - required: true, - type: String - }, - allowedLanguages: [ - { - ref: PROGRAMMING_LANGUAGE, - required: false, - type: mongoose.Schema.Types.ObjectId - } - ], - mode: { - required: true, - type: String - } -}); - -export default gameOptionsSchema; diff --git a/libs/backend/src/models/game/game.ts b/libs/backend/src/models/game/game.ts deleted file mode 100644 index 22fd48e8..00000000 --- a/libs/backend/src/models/game/game.ts +++ /dev/null @@ -1,51 +0,0 @@ -import mongoose, { Schema, Document } from "mongoose"; -import { GAME, PUZZLE, SUBMISSION, USER } from "../../utils/constants/model.js"; -import { DEFAULT_GAME_LENGTH_IN_MILLISECONDS, GameEntity } from "types"; -import gameOptionsSchema from "./game-config.js"; - -export interface GameDocument extends Document, GameEntity {} - -const gameSchema = new Schema({ - players: [ - { - ref: USER, - required: true, - type: Schema.Types.ObjectId - } - ], - createdAt: { - default: Date.now, - type: Date - }, - owner: { - ref: USER, - required: true, - type: mongoose.Schema.Types.ObjectId - }, - startTime: { - default: Date.now, - type: Date, - required: true - }, - endTime: { - default: () => Date.now() + DEFAULT_GAME_LENGTH_IN_MILLISECONDS, - type: Date, - required: true - }, - options: gameOptionsSchema, - puzzle: { - ref: PUZZLE, - required: true, - type: Schema.Types.ObjectId - }, - playerSubmissions: [ - { - ref: SUBMISSION, - required: false, - type: Schema.Types.ObjectId - } - ] -}); - -const Game = mongoose.model(GAME, gameSchema); -export default Game; diff --git a/libs/backend/src/models/moderation/user-ban.ts b/libs/backend/src/models/moderation/user-ban.ts deleted file mode 100644 index 11db1f39..00000000 --- a/libs/backend/src/models/moderation/user-ban.ts +++ /dev/null @@ -1,64 +0,0 @@ -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { UserBanEntity, banTypeEnum } from "types"; -import { USER_BAN, USER } from "../../utils/constants/model.js"; - -export interface UserBanDocument - extends Document, - Omit { - userId: ObjectId; - bannedBy: ObjectId; -} - -const userBanSchema = new Schema({ - userId: { - type: Schema.Types.ObjectId, - ref: USER, - required: true - }, - bannedBy: { - type: Schema.Types.ObjectId, - ref: USER, - required: true - }, - banType: { - type: String, - enum: [banTypeEnum.TEMPORARY, banTypeEnum.PERMANENT], - required: true - }, - reason: { - type: String, - required: true, - minlength: 10, - maxlength: 500 - }, - startDate: { - type: Date, - required: true, - default: Date.now - }, - endDate: { - type: Date, - required: false // Not required for permanent bans - }, - isActive: { - type: Boolean, - default: true - }, - createdAt: { - type: Date, - default: Date.now - }, - updatedAt: { - type: Date, - default: Date.now - } -}); - -// Indexes for efficient querying -userBanSchema.index({ userId: 1, isActive: 1 }); -userBanSchema.index({ userId: 1, createdAt: -1 }); -userBanSchema.index({ endDate: 1, isActive: 1 }); // For expiration checks - -const UserBan = mongoose.model(USER_BAN, userBanSchema); - -export default UserBan; diff --git a/libs/backend/src/models/preferences/preferences-editor.ts b/libs/backend/src/models/preferences/preferences-editor.ts deleted file mode 100644 index 79f69084..00000000 --- a/libs/backend/src/models/preferences/preferences-editor.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Schema } from "mongoose"; -import { EditorPreferences } from "types"; - -export const preferencesEditor = new Schema({ - keymap: { - type: String, - required: false - }, - allowMultipleSelections: { - type: Boolean, - required: false - }, - autocompletion: { - type: Boolean, - required: false - }, - bracketMatching: { - type: Boolean, - required: false - }, - closeBrackets: { - type: Boolean, - required: false - }, - completionKeymap: { - type: Boolean, - required: false - }, - crosshairCursor: { - type: Boolean, - required: false - }, - defaultKeymap: { - type: Boolean, - required: false - }, - drawSelection: { - type: Boolean, - required: false - }, - dropCursor: { - type: Boolean, - required: false - }, - foldGutter: { - type: Boolean, - required: false - }, - foldKeymap: { - type: Boolean, - required: false - }, - highlightActiveLine: { - type: Boolean, - required: false - }, - highlightActiveLineGutter: { - type: Boolean, - required: false - }, - highlightSelectionMatches: { - type: Boolean, - required: false - }, - highlightSpecialChars: { - type: Boolean, - required: false - }, - history: { - type: Boolean, - required: false - }, - indentOnInput: { - type: Boolean, - required: false - }, - lineNumbers: { - type: Boolean, - required: false - }, - lintKeymap: { - type: Boolean, - required: false - }, - rectangularSelection: { - type: Boolean, - required: false - }, - searchKeymap: { - type: Boolean, - required: false - } -}); diff --git a/libs/backend/src/models/preferences/preferences.ts b/libs/backend/src/models/preferences/preferences.ts deleted file mode 100644 index f14889c4..00000000 --- a/libs/backend/src/models/preferences/preferences.ts +++ /dev/null @@ -1,51 +0,0 @@ -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { PreferencesEntity } from "types"; -import { PREFERENCES, USER } from "../../utils/constants/model.js"; -import { preferencesEditor } from "./preferences-editor.js"; - -export interface PreferencesDocument - extends Document, - Omit { - owner: ObjectId; -} - -const preferencesSchema = new Schema( - { - owner: { - ref: USER, - required: true, - type: mongoose.Schema.Types.ObjectId, - index: true, - unique: true - }, - blockedUsers: { - required: false, - type: [ - { - ref: USER, - required: false, - type: mongoose.Schema.Types.ObjectId - } - ] - }, - preferredLanguage: { - type: String, - required: false - }, - theme: { - type: String, - required: false - }, - editor: { - type: preferencesEditor, - required: false - } - }, - { timestamps: true } -); - -const Preferences = mongoose.model( - PREFERENCES, - preferencesSchema -); -export default Preferences; diff --git a/libs/backend/src/models/programming-language/language.ts b/libs/backend/src/models/programming-language/language.ts deleted file mode 100644 index 5222a5b9..00000000 --- a/libs/backend/src/models/programming-language/language.ts +++ /dev/null @@ -1,49 +0,0 @@ -import mongoose, { Document, Schema } from "mongoose"; -import { ProgrammingLanguageEntity } from "types"; -import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js"; - -export interface ProgrammingLanguageDocument - extends Document, - Omit { - _id: mongoose.Types.ObjectId; -} - -const programmingLanguageSchema = new Schema( - { - language: { - required: true, - type: String, - trim: true, - index: true - }, - version: { - required: true, - type: String, - trim: true, - index: true - }, - aliases: { - type: [String], - default: [], - required: false - }, - runtime: { - type: String, - required: false, - trim: true - } - }, - { - timestamps: true - } -); - -// Compound unique index to ensure language+version combination is unique -programmingLanguageSchema.index({ language: 1, version: 1 }, { unique: true }); - -const ProgrammingLanguage = mongoose.model( - PROGRAMMING_LANGUAGE, - programmingLanguageSchema -); - -export default ProgrammingLanguage; diff --git a/libs/backend/src/models/puzzle/puzzle.ts b/libs/backend/src/models/puzzle/puzzle.ts deleted file mode 100644 index 7f7c8a41..00000000 --- a/libs/backend/src/models/puzzle/puzzle.ts +++ /dev/null @@ -1,102 +0,0 @@ -import mongoose, { Schema, Document, ObjectId } from "mongoose"; -import { PUZZLE, USER, METRICS, COMMENT } from "../../utils/constants/model.js"; -import { - DifficultyEnum, - PuzzleEntity, - puzzleVisibilityEnum, - Solution -} from "types"; -import solutionSchema from "./solution.js"; -import validatorSchema from "./validator.js"; -import Comment from "../comment/comment.js"; - -export interface PuzzleDocument - extends Document, - Omit { - author: ObjectId; - solution?: Solution; -} - -/** - * IDEA: Eventually add puzzle types - * offering different play modes and play styles - */ -const puzzleSchema = new Schema({ - title: { - required: true, - trim: true, - type: String - }, - statement: { - trim: true, - type: String - }, - constraints: { - trim: true, - type: String - }, - author: { - ref: USER, - required: true, - type: mongoose.Schema.Types.ObjectId - }, - validators: [validatorSchema], - difficulty: { - enum: Object.values(DifficultyEnum), - default: DifficultyEnum.INTERMEDIATE, - required: true, - type: String - }, - visibility: { - enum: Object.values(puzzleVisibilityEnum), - default: puzzleVisibilityEnum.DRAFT, - required: true, - type: String - }, - createdAt: { - default: Date.now, - type: Date - }, - updatedAt: { - default: Date.now, - type: Date - }, - solution: { - type: solutionSchema, - select: false, - default: () => ({ code: "", programmingLanguage: undefined }) - }, - puzzleMetrics: { - ref: METRICS, - type: Schema.Types.ObjectId, - select: false - }, - tags: [ - { - type: String - } - ], - comments: [ - { - ref: COMMENT, - type: Schema.Types.ObjectId - } - ], - moderationFeedback: { - type: String, - required: false - } -}); - -puzzleSchema.pre( - "deleteOne", - { document: true, query: false }, - async function (next) { - await Comment.deleteMany({ _id: { $in: this.comments } }); - - next(); - } -); - -const Puzzle = mongoose.model(PUZZLE, puzzleSchema); -export default Puzzle; diff --git a/libs/backend/src/models/puzzle/solution.ts b/libs/backend/src/models/puzzle/solution.ts deleted file mode 100644 index 38d2db49..00000000 --- a/libs/backend/src/models/puzzle/solution.ts +++ /dev/null @@ -1,17 +0,0 @@ -import mongoose, { Schema } from "mongoose"; -import { Solution } from "types"; -import { PROGRAMMING_LANGUAGE } from "../../utils/constants/model.js"; - -const solutionSchema = new Schema({ - code: { - required: false, - type: String - }, - programmingLanguage: { - ref: PROGRAMMING_LANGUAGE, - required: false, - type: mongoose.Schema.Types.ObjectId - } -}); - -export default solutionSchema; diff --git a/libs/backend/src/models/puzzle/validator.ts b/libs/backend/src/models/puzzle/validator.ts deleted file mode 100644 index 26d291ad..00000000 --- a/libs/backend/src/models/puzzle/validator.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Schema } from "mongoose"; - -/** - * IDEA: Eventually add validator types - * there could be different validators, hidden generated by the system, and public testcases - * this could mitigate users that just look at the public test cases and thus help catch code that technically is wrong, but passes the visible validators (to all users). - */ -const validatorSchema = new Schema({ - createdAt: { - default: Date.now, - type: Date - }, - input: { - required: true, - type: String - }, - output: { - required: true, - type: String - }, - updatedAt: { - default: Date.now, - type: Date - } -}); - -export default validatorSchema; diff --git a/libs/backend/src/models/report/report.ts b/libs/backend/src/models/report/report.ts deleted file mode 100644 index f744710d..00000000 --- a/libs/backend/src/models/report/report.ts +++ /dev/null @@ -1,69 +0,0 @@ -import mongoose, { Document, Schema } from "mongoose"; -import { ProblemTypeEnum, ReportEntity, reviewStatusEnum } from "types"; -import { REPORT, USER } from "../../utils/constants/model.js"; - -export interface ReportDocument - extends Document, - Omit { - reportedBy: mongoose.Types.ObjectId; - resolvedBy?: mongoose.Types.ObjectId; - problematicIdentifier: mongoose.Types.ObjectId; -} - -const reportSchema = new Schema({ - problematicIdentifier: { - type: Schema.Types.ObjectId, - required: true, - refPath: "problemType" - }, - problemType: { - type: String, - required: true, - enum: [ - ProblemTypeEnum.PUZZLE, - ProblemTypeEnum.USER, - ProblemTypeEnum.COMMENT, - ProblemTypeEnum.GAME_CHAT - ] - }, - reportedBy: { - type: Schema.Types.ObjectId, - ref: USER, - required: true - }, - explanation: { - type: String, - required: true - }, - status: { - type: String, - enum: [ - reviewStatusEnum.PENDING, - reviewStatusEnum.RESOLVED, - reviewStatusEnum.REJECTED - ], - default: reviewStatusEnum.PENDING, - required: true - }, - resolvedBy: { - type: Schema.Types.ObjectId, - ref: USER, - required: false - }, - createdAt: { - type: Date, - default: Date.now - }, - updatedAt: { - type: Date, - default: Date.now - } -}); - -// Create indexes for common queries -reportSchema.index({ status: 1, createdAt: -1 }); -reportSchema.index({ problemType: 1, status: 1 }); -reportSchema.index({ reportedBy: 1 }); - -const Report = mongoose.model(REPORT, reportSchema); -export default Report; diff --git a/libs/backend/src/models/submission/result-info.ts b/libs/backend/src/models/submission/result-info.ts deleted file mode 100644 index 7b8456d8..00000000 --- a/libs/backend/src/models/submission/result-info.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Schema } from "mongoose"; -import { PuzzleResultInformation } from "types"; - -export const resultInfoSchema = new Schema({ - result: { - type: String, - required: true - }, - successRate: { - type: Number, - required: true - } -}); diff --git a/libs/backend/src/models/submission/submission.ts b/libs/backend/src/models/submission/submission.ts deleted file mode 100644 index cc4031c7..00000000 --- a/libs/backend/src/models/submission/submission.ts +++ /dev/null @@ -1,54 +0,0 @@ -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { - PROGRAMMING_LANGUAGE, - PUZZLE, - SUBMISSION, - USER -} from "../../utils/constants/model.js"; -import { SubmissionEntity } from "types"; -import { resultInfoSchema } from "./result-info.js"; - -export interface SubmissionDocument - extends Document, - Omit { - puzzle: ObjectId; - user: ObjectId; - programmingLanguage: ObjectId; -} - -const submissionSchema = new Schema({ - code: { - required: true, - type: String, - select: false - }, - createdAt: { - default: Date.now, - type: Date - }, - puzzle: { - ref: PUZZLE, - required: true, - type: mongoose.Schema.Types.ObjectId - }, - result: { - required: true, - type: resultInfoSchema - }, - user: { - ref: USER, - required: true, - type: mongoose.Schema.Types.ObjectId - }, - programmingLanguage: { - ref: PROGRAMMING_LANGUAGE, - required: true, - type: mongoose.Schema.Types.ObjectId - } -}); - -const Submission = mongoose.model( - SUBMISSION, - submissionSchema -); -export default Submission; diff --git a/libs/backend/src/models/user-metrics/user-metrics.ts b/libs/backend/src/models/user-metrics/user-metrics.ts deleted file mode 100644 index 65759490..00000000 --- a/libs/backend/src/models/user-metrics/user-metrics.ts +++ /dev/null @@ -1,177 +0,0 @@ -import mongoose, { Document, Schema } from "mongoose"; -import { UserMetricsEntity } from "types"; -import { USER_METRICS, USER } from "../../utils/constants/model.js"; - -export interface UserMetricsDocument - extends Document, - Omit { - userId: mongoose.Types.ObjectId; -} - -const glickoRatingSchema = new Schema( - { - rating: { - type: Number, - default: 1500 - }, - rd: { - type: Number, - default: 350 - }, - volatility: { - type: Number, - default: 0.06 - }, - lastUpdated: { - type: Date, - default: Date.now - } - }, - { _id: false } -); - -const gameModeMetricsSchema = new Schema( - { - gamesPlayed: { - type: Number, - default: 0, - min: 0 - }, - gamesWon: { - type: Number, - default: 0, - min: 0 - }, - bestScore: { - type: Number, - default: 0, - min: 0 - }, - averageScore: { - type: Number, - default: 0, - min: 0 - }, - totalScore: { - type: Number, - default: 0, - min: 0 - }, - glickoRating: { - type: glickoRatingSchema, - default: () => ({}) - }, - rank: { - type: Number, - required: false - }, - lastGameDate: { - type: Date, - required: false - } - }, - { _id: false } -); - -const userMetricsSchema = new Schema({ - userId: { - type: Schema.Types.ObjectId, - ref: USER, - required: true, - unique: true, - index: true - }, - - // Metrics per game mode - fastest: { - type: gameModeMetricsSchema, - required: false - }, - shortest: { - type: gameModeMetricsSchema, - required: false - }, - backwards: { - type: gameModeMetricsSchema, - required: false - }, - hardcore: { - type: gameModeMetricsSchema, - required: false - }, - debug: { - type: gameModeMetricsSchema, - required: false - }, - typeracer: { - type: gameModeMetricsSchema, - required: false - }, - efficiency: { - type: gameModeMetricsSchema, - required: false - }, - incremental: { - type: gameModeMetricsSchema, - required: false - }, - random: { - type: gameModeMetricsSchema, - required: false - }, - - // Overall stats - totalGamesPlayed: { - type: Number, - default: 0, - min: 0 - }, - totalGamesWon: { - type: Number, - default: 0, - min: 0 - }, - - // Tracking for incremental updates - lastProcessedGameDate: { - type: Date, - default: () => new Date(0) // Unix epoch - }, - lastCalculationDate: { - type: Date, - default: Date.now - }, - - createdAt: { - type: Date, - default: Date.now - }, - updatedAt: { - type: Date, - default: Date.now - } -}); - -// Update timestamp on save -userMetricsSchema.pre("save", function (next) { - this.updatedAt = new Date(); - next(); -}); - -// Compound index for leaderboard queries per game mode -userMetricsSchema.index({ "fastest.glickoRating.rating": -1 }); -userMetricsSchema.index({ "shortest.glickoRating.rating": -1 }); -userMetricsSchema.index({ "backwards.glickoRating.rating": -1 }); -userMetricsSchema.index({ "hardcore.glickoRating.rating": -1 }); -userMetricsSchema.index({ "debug.glickoRating.rating": -1 }); -userMetricsSchema.index({ "typeracer.glickoRating.rating": -1 }); -userMetricsSchema.index({ "efficiency.glickoRating.rating": -1 }); -userMetricsSchema.index({ "incremental.glickoRating.rating": -1 }); -userMetricsSchema.index({ "random.glickoRating.rating": -1 }); - -const UserMetrics = mongoose.model( - USER_METRICS, - userMetricsSchema -); - -export default UserMetrics; diff --git a/libs/backend/src/models/user/user-profile.ts b/libs/backend/src/models/user/user-profile.ts deleted file mode 100644 index 3e700e1f..00000000 --- a/libs/backend/src/models/user/user-profile.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Schema } from "mongoose"; -import { UserProfile } from "types"; - -export const profileSchema = new Schema({ - picture: { - type: String, - required: false, - trim: true - }, - bio: { - type: String, - required: false, - trim: true - }, - location: { - type: String, - required: false, - trim: true - }, - socials: { - required: false, - type: [ - { - type: String, - trim: true - } - ] - } -}); diff --git a/libs/backend/src/models/user/user-vote.ts b/libs/backend/src/models/user/user-vote.ts deleted file mode 100644 index 03e0a405..00000000 --- a/libs/backend/src/models/user/user-vote.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { USER, USER_VOTE } from "@/utils/constants/model.js"; -import mongoose, { Document, ObjectId, Schema } from "mongoose"; -import { UserVoteEntity } from "types"; - -interface UserVoteDocument extends Document, Omit { - author: ObjectId; -} - -const userVoteSchema = new Schema({ - createdAt: { - default: Date.now, - type: Date - }, - type: { - type: String, - required: true - }, - votedOn: { - type: String, - required: true - }, - author: { - ref: USER, - type: Schema.Types.ObjectId, - required: true - } -}); - -userVoteSchema.index({ author: 1, votedOn: 1 }, { background: true }); -userVoteSchema.index({ votedOn: 1 }, { background: true }); - -const UserVote = mongoose.model(USER_VOTE, userVoteSchema); -export default UserVote; diff --git a/libs/backend/src/models/user/user.ts b/libs/backend/src/models/user/user.ts deleted file mode 100644 index 6d49fcbd..00000000 --- a/libs/backend/src/models/user/user.ts +++ /dev/null @@ -1,81 +0,0 @@ -import mongoose, { Document, Schema } from "mongoose"; -import { DEFAULT_USER_ROLE, UserEntity } from "types"; -import bcrypt from "bcryptjs"; -import { USER, USER_BAN } from "../../utils/constants/model.js"; -import { profileSchema } from "./user-profile.js"; - -export interface UserDocument extends Document, Omit { - currentBan?: mongoose.Types.ObjectId | null; -} - -const userSchema = new Schema({ - createdAt: { - default: Date.now, - type: Date - }, - email: { - lowercase: true, - required: true, - trim: true, - type: String, - unique: true, - select: false, - index: true - }, - password: { - required: true, - type: String, - select: false - }, - updatedAt: { - default: Date.now, - type: Date - }, - username: { - required: true, - trim: true, - type: String, - unique: true, - index: true - }, - profile: { - type: profileSchema, - required: false - }, - role: { - type: String, - trim: true, - required: false, - default: () => DEFAULT_USER_ROLE - }, - reportCount: { - type: Number, - default: 0, - min: 0, - select: false - }, - banCount: { - type: Number, - default: 0, - min: 0, - select: false - }, - currentBan: { - type: Schema.Types.ObjectId, - ref: USER_BAN, - required: false, - default: null - } -}); - -// Pre-save hook to hashlutino password -userSchema.pre("save", async function (next) { - if (this.isModified("password")) { - this.password = await bcrypt.hash(this.password, 10); - } - - next(); -}); - -const User = mongoose.model(USER, userSchema); -export default User; diff --git a/libs/backend/src/plugins/config/cors.ts b/libs/backend/src/plugins/config/cors.ts deleted file mode 100644 index 363612a8..00000000 --- a/libs/backend/src/plugins/config/cors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import cors from "@fastify/cors"; -import { FastifyInstance } from "fastify"; -import fastifyPlugin from "fastify-plugin"; - -async function corsSetup(fastify: FastifyInstance) { - fastify.register(cors, { - allowedHeaders: ["Authorization", "Content-Type"], - credentials: true, - origin: process.env.FRONTEND_URL ?? "http://localhost:5173", - // Allow WebSocket upgrade headers - exposedHeaders: ["Upgrade", "Connection"] - }); -} - -export default fastifyPlugin(corsSetup); diff --git a/libs/backend/src/plugins/config/jwt.ts b/libs/backend/src/plugins/config/jwt.ts deleted file mode 100644 index 1f8060d5..00000000 --- a/libs/backend/src/plugins/config/jwt.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fastifyPlugin from "fastify-plugin"; -import jwt from "@fastify/jwt"; -import { FastifyInstance } from "fastify"; -import { cookieKeys } from "types"; - -async function jwtSetup(fastify: FastifyInstance) { - const JWT_SECRET = process.env.JWT_SECRET; - - if (!JWT_SECRET) { - throw new Error("JWT secret is not defined in environment variables"); - } - - fastify.register(jwt, { - secret: JWT_SECRET, - cookie: { - cookieName: cookieKeys.TOKEN, - signed: false - }, - formatUser: function (payload) { - // Return the payload as-is without any transformation or validation - // This prevents @fastify/jwt from doing any automatic schema validation - return payload; - }, - // Add decode option to see raw decoded payload - decode: { complete: false } - }); -} - -export default fastifyPlugin(jwtSetup); diff --git a/libs/backend/src/plugins/config/mongoose.ts b/libs/backend/src/plugins/config/mongoose.ts deleted file mode 100644 index a48068c8..00000000 --- a/libs/backend/src/plugins/config/mongoose.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { FastifyInstance } from "fastify"; -import mongoose from "mongoose"; - -export default async function mongooseConnector(fastify: FastifyInstance) { - const uri = process.env.MONGO_URI; - const dbName = process.env.MONGO_DB_NAME; - - if (!uri) { - throw new Error("MONGO_URI is not defined in environment variables"); - } - - try { - console.log("Connecting to MongoDB..."); - await mongoose.connect(uri, { - dbName: dbName ?? "codincod", - serverSelectionTimeoutMS: 5 * 1000, - connectTimeoutMS: 10 * 1000 - }); - console.log("MongoDB connected successfully!"); - mongoose.connection.on("connected", () => { - fastify.log.info({ actor: "MongoDB" }, "connected"); - }); - mongoose.connection.on("disconnected", () => { - fastify.log.error({ actor: "MongoDB" }, "disconnected"); - }); - } catch (error) { - console.error(`MongoDB connection error:`, error); - fastify.log.error(`MongoDB connection error (${error})`); - process.exit(1); - } - - fastify.decorate("mongoose", mongoose); -} diff --git a/libs/backend/src/plugins/config/setup-web-sockets.ts b/libs/backend/src/plugins/config/setup-web-sockets.ts deleted file mode 100644 index f52ea6f2..00000000 --- a/libs/backend/src/plugins/config/setup-web-sockets.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { waitingRoomSetup } from "@/websocket/waiting-room/waiting-room-setup.js"; -import { gameSetup } from "@/websocket/game/game-setup.js"; -import authenticated from "../middleware/authenticated.js"; -import { webSocketParams, webSocketUrls } from "types"; -import { ParamsId } from "@/types/types.js"; -import { ConnectionManager } from "@/websocket/connection-manager.js"; - -export async function setupWebSockets(fastify: FastifyInstance) { - const connectionManager = new ConnectionManager(); - - // Rate limit config for WebSocket upgrade requests - // This limits the initial connection attempts, not the messages sent over the connection - const wsRateLimit = { - max: 20, - timeWindow: "1 minute" - }; - - fastify.get( - webSocketUrls.WAITING_ROOM, - { - websocket: true, - preHandler: authenticated, - config: { - rateLimit: wsRateLimit - } - }, - (...props) => waitingRoomSetup(...props, fastify) - ); - - fastify.get( - webSocketUrls.gameById(webSocketParams.ID), - { - websocket: true, - preHandler: authenticated, - config: { - rateLimit: wsRateLimit - } - }, - (...props) => gameSetup(...props, fastify) - ); - - fastify.addHook("onClose", async () => { - fastify.log.info("Shutting down WebSocket connections..."); - connectionManager.destroy(); - }); -} diff --git a/libs/backend/src/plugins/decorators/piston.ts b/libs/backend/src/plugins/decorators/piston.ts deleted file mode 100644 index 93cc077e..00000000 --- a/libs/backend/src/plugins/decorators/piston.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { buildPistonUri } from "@/utils/functions/build-piston-uri.js"; -import { FastifyInstance } from "fastify"; -import fastifyPlugin from "fastify-plugin"; -import { - arePistonRuntimes, - ErrorResponse, - httpRequestMethod, - isPistonExecutionResponse, - PistonExecutionRequest, - pistonUrls, - POST -} from "types"; - -async function piston(fastify: FastifyInstance) { - fastify.decorate( - "piston", - async (pistonExecutionRequestObject: PistonExecutionRequest) => { - const res = await fetch(buildPistonUri(pistonUrls.EXECUTE), { - method: POST, - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(pistonExecutionRequestObject) - }); - - const executionResponse = await res.json(); - - if (!isPistonExecutionResponse(executionResponse)) { - const error: ErrorResponse = { - error: "Unknown error with piston", - message: "response is not a piston execution response" - }; - - return error; - } - - return executionResponse; - } - ); - - fastify.decorate("runtimes", async () => { - const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES), { - method: httpRequestMethod.GET, - headers: { - "Content-Type": "application/json" - } - }); - - if (!response.ok) { - throw new Error( - `Failed to execute code: ${response.status} - ${response.statusText}` - ); - } - - const pistonRuntimesResponse = await response.json(); - - if (!arePistonRuntimes(pistonRuntimesResponse)) { - const error: ErrorResponse = { - error: "Unknown error with piston", - message: "response are not a piston runtimes" - }; - - return error; - } - - return pistonRuntimesResponse; - }); -} - -export default fastifyPlugin(piston); diff --git a/libs/backend/src/plugins/middleware/authenticated.ts b/libs/backend/src/plugins/middleware/authenticated.ts deleted file mode 100644 index 12dc75a1..00000000 --- a/libs/backend/src/plugins/middleware/authenticated.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { FastifyReply, FastifyRequest } from "fastify"; -import { httpResponseCodes, cookieKeys, AuthenticatedInfo } from "types"; - -export default async function authenticated( - request: FastifyRequest, - reply: FastifyReply -) { - try { - const token = request.cookies[cookieKeys.TOKEN]; - - if (!token) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ message: "No authentication token provided" }); - } - - await request.jwtVerify(); - } catch (err) { - if (err instanceof Error) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: err.message }); - } - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: "An unexpected error occurred." }); - } -} diff --git a/libs/backend/src/plugins/middleware/check-user-ban.ts b/libs/backend/src/plugins/middleware/check-user-ban.ts deleted file mode 100644 index 0414fa3c..00000000 --- a/libs/backend/src/plugins/middleware/check-user-ban.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { FastifyReply, FastifyRequest } from "fastify"; -import { httpResponseCodes, isAuthenticatedInfo, banTypeEnum } from "types"; -import { checkUserBanStatus } from "../../utils/moderation/escalation.js"; - -export default async function checkUserBan( - request: FastifyRequest, - reply: FastifyReply -): Promise { - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Authentication required" }); - } - - const { isBanned, ban } = await checkUserBanStatus(request.user.userId); - - if (isBanned && ban) { - const message = - ban.banType === banTypeEnum.PERMANENT - ? `You have been permanently banned. Reason: ${ban.reason}` - : `You are temporarily banned until ${ban.endDate?.toISOString()}. Reason: ${ban.reason}`; - - return reply.status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN).send({ - error: message, - banDetails: { - type: ban.banType, - reason: ban.reason, - endDate: ban.endDate - } - }); - } -} diff --git a/libs/backend/src/plugins/middleware/decode-token.ts b/libs/backend/src/plugins/middleware/decode-token.ts deleted file mode 100644 index fff60e7e..00000000 --- a/libs/backend/src/plugins/middleware/decode-token.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FastifyRequest } from "fastify"; -import { AuthenticatedInfo } from "types"; - -export default async function decodeToken(request: FastifyRequest) { - try { - // Set the Authorization header for jwt.verify to use - // for some dumb reason it searches on headers.authorization, lost quite a bit of time on this one - request.headers.authorization = `bearer ${request.cookies.token}`; - - const decoded = await request.jwtVerify(); - - request.user = decoded; - } catch (err) {} -} diff --git a/libs/backend/src/plugins/middleware/moderator-only.ts b/libs/backend/src/plugins/middleware/moderator-only.ts deleted file mode 100644 index 3449acaf..00000000 --- a/libs/backend/src/plugins/middleware/moderator-only.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FastifyReply, FastifyRequest } from "fastify"; -import { httpResponseCodes, isAuthenticatedInfo, isModerator } from "types"; -import User from "../../models/user/user.js"; - -export default async function moderatorOnly( - request: FastifyRequest, - reply: FastifyReply -) { - try { - const token = request.cookies.token; - - if (!token) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ message: "No authentication token provided" }); - } - - await request.jwtVerify(); - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ message: "Invalid credentials" }); - } - - const userId = request.user.userId; - const user = await User.findById(userId); - - if (!user || !isModerator(user.role)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN) - .send({ message: "Moderator access required" }); - } - } catch (err) { - if (err instanceof Error) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: err.message }); - } - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: "An unexpected error occurred." }); - } -} diff --git a/libs/backend/src/plugins/middleware/request-logger.ts b/libs/backend/src/plugins/middleware/request-logger.ts deleted file mode 100644 index fad3f1d0..00000000 --- a/libs/backend/src/plugins/middleware/request-logger.ts +++ /dev/null @@ -1,12 +0,0 @@ -import fastifyPlugin from "fastify-plugin"; -import { FastifyInstance } from "fastify"; - -async function requestLogger(fastify: FastifyInstance) { - fastify.addHook("preHandler", async (request) => { - console.log( - `[${new Date().toISOString()}] ${request.method} ${request.url}` - ); - }); -} - -export default fastifyPlugin(requestLogger); diff --git a/libs/backend/src/plugins/middleware/validate-body.ts b/libs/backend/src/plugins/middleware/validate-body.ts deleted file mode 100644 index 83e9a280..00000000 --- a/libs/backend/src/plugins/middleware/validate-body.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { FastifyReply, FastifyRequest } from "fastify"; -import { z, ZodSchema } from "zod"; -import { httpResponseCodes, type ErrorResponse } from "types"; - -/** - * Creates a validation middleware for request body validation using Zod schemas - * - * This middleware provides consistent error handling across all routes - * and ensures type safety at runtime. - * - * @param schema - Zod schema to validate the request body against - * @returns Fastify preHandler hook function - * - * @example - * ```typescript - * import { validateBody } from '@/plugins/middleware/validate-body.js'; - * import { registerSchema } from 'types'; - * - * fastify.post( - * '/', - * { preHandler: validateBody(registerSchema) }, - * async (request, reply) => { - * // request.body is now typed and validated - * const { email, password, username } = request.body; - * // ... - * } - * ); - * ``` - */ -export function validateBody(schema: T) { - return async ( - request: FastifyRequest, - reply: FastifyReply - ): Promise => { - try { - // Parse and validate the request body - request.body = schema.parse(request.body); - } catch (error) { - if (error instanceof z.ZodError) { - // Format Zod validation errors for client - const formattedErrors = error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code - })); - - const errorResponse: ErrorResponse = { - error: "Validation Error", - message: "Request validation failed", - details: formattedErrors - }; - - reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send(errorResponse); - return; - } - - // Unexpected error during validation - request.log.error({ err: error }, "Unexpected validation error"); - const errorResponse: ErrorResponse = { - error: "Internal Server Error", - message: "An unexpected error occurred during validation" - }; - - reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(errorResponse); - } - }; -} - -/** - * Creates a validation middleware for query parameters using Zod schemas - * - * @param schema - Zod schema to validate the query parameters against - * @returns Fastify preHandler hook function - * - * @example - * ```typescript - * import { validateQuery } from '@/plugins/middleware/validate-body.js'; - * import { paginatedQuerySchema } from 'types'; - * - * fastify.get( - * '/', - * { preHandler: validateQuery(paginatedQuerySchema) }, - * async (request, reply) => { - * const { page, pageSize } = request.query; - * // ... - * } - * ); - * ``` - */ -export function validateQuery(schema: T) { - return async ( - request: FastifyRequest, - reply: FastifyReply - ): Promise => { - try { - request.query = schema.parse(request.query); - } catch (error) { - if (error instanceof z.ZodError) { - const formattedErrors = error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code - })); - - const errorResponse: ErrorResponse = { - error: "Validation Error", - message: "Query parameter validation failed", - details: formattedErrors - }; - - reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send(errorResponse); - return; - } - - request.log.error({ err: error }, "Unexpected query validation error"); - const errorResponse: ErrorResponse = { - error: "Internal Server Error", - message: "An unexpected error occurred during query validation" - }; - - reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(errorResponse); - } - }; -} - -/** - * Creates a validation middleware for route parameters using Zod schemas - * - * @param schema - Zod schema to validate the route params against - * @returns Fastify preHandler hook function - * - * @example - * ```typescript - * import { validateParams } from '@/plugins/middleware/validate-body.js'; - * import { z } from 'zod'; - * - * const paramsSchema = z.object({ - * id: z.string().min(1) - * }); - * - * fastify.get( - * '/:id', - * { preHandler: validateParams(paramsSchema) }, - * async (request, reply) => { - * const { id } = request.params; - * // ... - * } - * ); - * ``` - */ -export function validateParams(schema: T) { - return async ( - request: FastifyRequest, - reply: FastifyReply - ): Promise => { - try { - request.params = schema.parse(request.params); - } catch (error) { - if (error instanceof z.ZodError) { - const formattedErrors = error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code - })); - - const errorResponse: ErrorResponse = { - error: "Validation Error", - message: "Route parameter validation failed", - details: formattedErrors - }; - - reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send(errorResponse); - return; - } - - request.log.error({ err: error }, "Unexpected params validation error"); - const errorResponse: ErrorResponse = { - error: "Internal Server Error", - message: "An unexpected error occurred during parameter validation" - }; - - reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(errorResponse); - } - }; -} diff --git a/libs/backend/src/router.ts b/libs/backend/src/router.ts deleted file mode 100644 index fbd7731a..00000000 --- a/libs/backend/src/router.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { FastifyInstance } from "fastify"; -import healthRoutes from "./routes/health/index.js"; -import puzzleRoutes from "./routes/puzzle/index.js"; -import submissionRoutes from "./routes/submission/index.js"; -import indexRoutes from "./routes/index.js"; -import registerRoutes from "./routes/register/index.js"; -import loginRoutes from "./routes/login/index.js"; -import logoutRoutes from "./routes/logout/index.js"; -import userRoutes from "./routes/user/index.js"; -import { backendParams, backendUrls, banTypeEnum } from "types"; -import puzzleByIdRoutes from "./routes/puzzle/[id]/index.js"; -import executeRoutes from "./routes/execute/index.js"; -import userByUsernameIsAvailableRoutes from "./routes/user/[username]/isAvailable/index.js"; -import userByUsernameRoutes from "./routes/user/[username]/index.js"; -import userByUsernameActivityRoutes from "./routes/user/[username]/activity/index.js"; -import submissionGameRoutes from "./routes/submission/game/index.js"; -import submissionByIdRoutes from "./routes/submission/[id]/index.js"; -import preferencesRoutes from "./routes/account/preferences/index.js"; -import puzzleByIdSolutionRoutes from "./routes/puzzle/[id]/solution/index.js"; -import puzzleByIdCommentRoutes from "./routes/puzzle/[id]/comment/index.js"; -import commentByIdRoutes from "./routes/comment/[id]/index.js"; -import commentByIdVoteRoutes from "./routes/comment/[id]/vote/index.js"; -import commentByIdCommentRoutes from "./routes/comment/[id]/comment/index.js"; -import userByUsernamePuzzleRoutes from "./routes/user/[username]/puzzle/index.js"; -import accountRoutes from "./routes/account/index.js"; -import reportRoutes from "./routes/report/index.js"; -import moderationReportByIdRoutes from "./routes/moderation/report/[id]/resolve/index.js"; -import moderationReviewRoutes from "./routes/moderation/review/index.js"; -import moderationPuzzleByIdApproveRoutes from "./routes/moderation/puzzle/[id]/approve/index.js"; -import moderationPuzzleByIdReviseRoutes from "./routes/moderation/puzzle/[id]/revise/index.js"; -import moderationUserByIdBanUnbanRoutes from "./routes/moderation/user/[id]/unban/index.js"; -import moderationUserByIdBanHistoryRoutes from "./routes/moderation/user/[id]/ban/history/index.js"; -import moderationUserByIdBanPermanentRoutes from "./routes/moderation/user/[id]/ban/permanent/index.js"; -import moderationUserByIdBanTemporaryRoutes from "./routes/moderation/user/[id]/ban/temporary/index.js"; -import programmingLanguageRoutes from "./routes/programming-language/index.js"; -import programmingLanguageByIdRoutes from "./routes/programming-language/[id]/index.js"; -import leaderboardByGameModeRoutes from "./routes/leaderboard/[gameMode]/index.js"; -import leaderboardRecalculateRoutes from "./routes/leaderboard/recalculate/index.js"; -import leaderboardUserByIdRoutes from "./routes/leaderboard/user/[id]/index.js"; - -export default async function router(fastify: FastifyInstance) { - fastify.register(indexRoutes, { prefix: backendUrls.ROOT }); - fastify.register(registerRoutes, { prefix: backendUrls.REGISTER }); - fastify.register(loginRoutes, { prefix: backendUrls.LOGIN }); - fastify.register(logoutRoutes, { prefix: backendUrls.LOGOUT }); - fastify.register(userRoutes, { prefix: backendUrls.USER }); - fastify.register(accountRoutes, { prefix: backendUrls.ACCOUNT }); - fastify.register(userByUsernameRoutes, { - prefix: backendUrls.userByUsername(backendParams.USERNAME) - }); - fastify.register(userByUsernameActivityRoutes, { - prefix: backendUrls.userByUsernameActivity(backendParams.USERNAME) - }); - fastify.register(userByUsernamePuzzleRoutes, { - prefix: backendUrls.userByUsernamePuzzle(backendParams.USERNAME) - }); - fastify.register(userByUsernameIsAvailableRoutes, { - prefix: backendUrls.userByUsernameIsAvailable(backendParams.USERNAME) - }); - fastify.register(puzzleRoutes, { prefix: backendUrls.PUZZLE }); - fastify.register(healthRoutes, { prefix: backendUrls.HEALTH }); - fastify.register(executeRoutes, { prefix: backendUrls.EXECUTE }); - fastify.register(submissionRoutes, { prefix: backendUrls.SUBMISSION }); - fastify.register(submissionByIdRoutes, { - prefix: backendUrls.submissionById(backendParams.ID) - }); - fastify.register(submissionGameRoutes, { - prefix: backendUrls.SUBMISSION_GAME - }); - fastify.register(programmingLanguageRoutes, { - prefix: backendUrls.PROGRAMMING_LANGUAGE - }); - fastify.register(programmingLanguageByIdRoutes, { - prefix: backendUrls.programmingLanguageById(backendParams.ID) - }); - fastify.register(puzzleByIdRoutes, { - prefix: backendUrls.puzzleById(backendParams.ID) - }); - fastify.register(puzzleByIdCommentRoutes, { - prefix: backendUrls.puzzleByIdComment(backendParams.ID) - }); - fastify.register(puzzleByIdSolutionRoutes, { - prefix: backendUrls.puzzleByIdSolution(backendParams.ID) - }); - fastify.register(commentByIdRoutes, { - prefix: backendUrls.commentById(backendParams.ID) - }); - fastify.register(commentByIdCommentRoutes, { - prefix: backendUrls.commentByIdComment(backendParams.ID) - }); - fastify.register(commentByIdVoteRoutes, { - prefix: backendUrls.commentByIdVote(backendParams.ID) - }); - fastify.register(preferencesRoutes, { - prefix: backendUrls.ACCOUNT_PREFERENCES - }); - fastify.register(reportRoutes, { prefix: backendUrls.REPORT }); - fastify.register(moderationReviewRoutes, { - prefix: backendUrls.MODERATION_REVIEW - }); - fastify.register(moderationPuzzleByIdApproveRoutes, { - prefix: backendUrls.moderationPuzzleApprove(backendParams.ID) - }); - fastify.register(moderationPuzzleByIdReviseRoutes, { - prefix: backendUrls.moderationPuzzleRevise(backendParams.ID) - }); - fastify.register(moderationReportByIdRoutes, { - prefix: backendUrls.moderationReportResolve(backendParams.ID) - }); - fastify.register(moderationUserByIdBanUnbanRoutes, { - prefix: backendUrls.moderationUserByIdUnban(backendParams.ID) - }); - fastify.register(moderationUserByIdBanHistoryRoutes, { - prefix: backendUrls.moderationUserByIdBanHistory(backendParams.ID) - }); - fastify.register(moderationUserByIdBanPermanentRoutes, { - prefix: backendUrls.moderationUserByIdBanByType( - backendParams.ID, - banTypeEnum.PERMANENT - ) - }); - fastify.register(moderationUserByIdBanTemporaryRoutes, { - prefix: backendUrls.moderationUserByIdBanByType( - backendParams.ID, - banTypeEnum.TEMPORARY - ) - }); - fastify.register(leaderboardByGameModeRoutes, { - prefix: backendUrls.leaderboardByGameMode(backendParams.GAME_MODE) - }); - fastify.register(leaderboardRecalculateRoutes, { - prefix: backendUrls.LEADERBOARD_RECALCULATE - }); - fastify.register(leaderboardUserByIdRoutes, { - prefix: backendUrls.leaderboardUserById(backendParams.ID) - }); -} diff --git a/libs/backend/src/routes/account/index.ts b/libs/backend/src/routes/account/index.ts deleted file mode 100644 index ae787e5a..00000000 --- a/libs/backend/src/routes/account/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { FastifyInstance } from "fastify"; -import authenticated from "../../plugins/middleware/authenticated.js"; -import checkUserBan from "../../plugins/middleware/check-user-ban.js"; -import { AuthenticatedInfo, DEFAULT_USER_ROLE, httpResponseCodes } from "types"; -import User from "../../models/user/user.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; -import { z } from "zod"; - -const updateProfileSchema = z.object({ - bio: z.string().max(500).optional(), - location: z.string().max(100).optional(), - picture: z.string().url().optional().or(z.literal("")), - socials: z.array(z.string().url()).max(5).optional() -}); - -export default async function accountRoutes(fastify: FastifyInstance) { - // GET /account - Get current user info - fastify.get( - "/", - { - preHandler: [authenticated, checkUserBan] - }, - async (request, reply) => { - const user = request.user as AuthenticatedInfo | undefined; - - if (!user) { - return reply.status(401).send({ - isAuthenticated: false, - message: "Not authenticated" - }); - } - - try { - // Fetch the user from database to get the role - const dbUser = await User.findById(user.userId); - - return reply.status(200).send({ - isAuthenticated: true, - userId: user.userId, - username: user.username, - role: dbUser?.role || DEFAULT_USER_ROLE - }); - } catch (error) { - fastify.log.error(error, "Failed to fetch user data"); - return reply.status(500).send({ - isAuthenticated: false, - message: "Failed to fetch user data" - }); - } - } - ); - - // PATCH /account/profile - Update user profile - fastify.patch( - "/profile", - { - preHandler: [ - authenticated, - checkUserBan, - validateBody(updateProfileSchema) - ] - }, - async (request, reply) => { - const user = request.user as AuthenticatedInfo | undefined; - - if (!user) { - return reply.status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED).send({ - message: "Not authenticated" - }); - } - - try { - const updates = request.body as z.infer; - - const dbUser = await User.findById(user.userId); - - if (!dbUser) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - message: "User not found" - }); - } - - // Update profile fields - if (!dbUser.profile) { - dbUser.profile = {}; - } - - if (updates.bio !== undefined) dbUser.profile.bio = updates.bio; - if (updates.location !== undefined) - dbUser.profile.location = updates.location; - if (updates.picture !== undefined) - dbUser.profile.picture = updates.picture; - if (updates.socials !== undefined) - dbUser.profile.socials = updates.socials; - - await dbUser.save(); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - message: "Profile updated successfully", - profile: dbUser.profile - }); - } catch (error) { - fastify.log.error(error, "Failed to update profile"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - message: "Failed to update profile" - }); - } - } - ); -} diff --git a/libs/backend/src/routes/account/preferences/index.ts b/libs/backend/src/routes/account/preferences/index.ts deleted file mode 100644 index f9d05a47..00000000 --- a/libs/backend/src/routes/account/preferences/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - preferencesDtoSchema -} from "types"; -import Preferences from "../../../models/preferences/preferences.js"; -import authenticated from "../../../plugins/middleware/authenticated.js"; -import checkUserBan from "../../../plugins/middleware/check-user-ban.js"; - -export default async function preferencesRoutes(fastify: FastifyInstance) { - fastify.get( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - - try { - const preferences = await Preferences.findOne({ owner: userId }); - - if (!preferences) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Preferences not found" }); - } - - return reply.send(preferences); - } catch (error) { - console.error("Failed to fetch preferences:", error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch preferences" }); - } - } - ); - - fastify.put( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const parseResult = preferencesDtoSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - const userId = request.user.userId; - - try { - const preferences = await Preferences.findOneAndUpdate( - { owner: userId }, - { ...parseResult.data, owner: userId }, - { new: true, runValidators: true, upsert: true } - ); - - return reply.send(preferences); - } catch (error) { - console.error("Failed to update preferences:", error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to update preferences" }); - } - } - ); - - fastify.delete( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - - try { - const deleted = await Preferences.findOneAndDelete({ owner: userId }); - - if (!deleted) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Preferences not found" }); - } - - return reply - .status(httpResponseCodes.SUCCESSFUL.NO_CONTENT) - .send(deleted); - } catch (error) { - console.error("Failed to delete preferences:", error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to delete preferences" }); - } - } - ); - - fastify.patch( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const parseResult = preferencesDtoSchema - .partial() - .safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - - try { - const preferences = await Preferences.findOneAndUpdate( - { owner: userId }, - { $set: parseResult.data }, - { new: true, runValidators: true, upsert: true } - ); - - return reply.send(preferences); - } catch (error) { - console.error("Failed to update preferences (PATCH):", error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to update preferences" }); - } - } - ); -} diff --git a/libs/backend/src/routes/comment/[id]/comment/index.ts b/libs/backend/src/routes/comment/[id]/comment/index.ts deleted file mode 100644 index 28b25dcd..00000000 --- a/libs/backend/src/routes/comment/[id]/comment/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -import Comment from "@/models/comment/comment.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { ParamsId } from "@/types/types.js"; -import { FastifyInstance } from "fastify"; -import { - CommentEntity, - commentTypeEnum, - createCommentSchema, - httpResponseCodes, - isAuthenticatedInfo -} from "types"; - -export default async function commentByIdCommentRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const parseResult = createCommentSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const id = request.params.id; - const user = request.user; - const userId = user.userId; - - const newCommentData: CommentEntity = { - ...parseResult.data, - author: userId, - upvote: 0, - downvote: 0, - comments: [], - commentType: commentTypeEnum.COMMENT, - parentId: id - }; - try { - const newComment = new Comment(newCommentData); - await newComment.save(); - - await Comment.findByIdAndUpdate( - id, - { $push: { comments: newComment._id } }, - { new: true } - ); - - const comment = await Comment.findById(newComment.id).populate( - "author" - ); - - return reply.status(httpResponseCodes.SUCCESSFUL.CREATED).send(comment); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to create comment" }); - } - } - ); -} diff --git a/libs/backend/src/routes/comment/[id]/index.ts b/libs/backend/src/routes/comment/[id]/index.ts deleted file mode 100644 index b3f193ef..00000000 --- a/libs/backend/src/routes/comment/[id]/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -import Comment from "@/models/comment/comment.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { ParamsId } from "@/types/types.js"; -import { FastifyInstance } from "fastify"; -import { commentTypeEnum, httpResponseCodes, objectIdSchema } from "types"; - -export default async function commentByIdRoutes(fastify: FastifyInstance) { - fastify.get("/", async (request, reply) => { - const parseResult = objectIdSchema.safeParse(request.params.id); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - try { - const comment = await Comment.findById(request.params.id) - .populate("author") - .populate("comments") - .populate({ - path: "comments", - populate: { - path: "author" - } - }); - - if (!comment) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Comment not found" }); - } - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(comment); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch comment" }); - } - }); - - fastify.delete( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - try { - const comment = await Comment.findById(request.params.id); - - if (!comment) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Comment not found" }); - } - - if (comment.commentType === commentTypeEnum.COMMENT) { - await Comment.findOneAndUpdate( - { comments: comment._id }, - // Remove the comment._id from the comments array - { $pull: { comments: comment._id } }, - { new: true } - ); - } else { - await Puzzle.findOneAndUpdate( - { comments: comment._id }, - { $pull: { comments: comment._id } }, - { new: true } - ); - } - - await comment.deleteOne(); - - return reply - .status(httpResponseCodes.SUCCESSFUL.NO_CONTENT) - .send(comment); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch comment" }); - } - } - ); -} diff --git a/libs/backend/src/routes/comment/[id]/vote/index.ts b/libs/backend/src/routes/comment/[id]/vote/index.ts deleted file mode 100644 index 9cbcbc60..00000000 --- a/libs/backend/src/routes/comment/[id]/vote/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -import Comment from "@/models/comment/comment.js"; -import UserVote from "@/models/user/user-vote.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; -import { ParamsId } from "@/types/types.js"; -import { FastifyInstance } from "fastify"; -import { - CommentVoteRequest, - commentVoteRequestSchema, - httpResponseCodes, - isAuthenticatedInfo, - voteTypeEnum -} from "types"; - -export default async function commentByIdVoteRoutes(fastify: FastifyInstance) { - fastify.post( - "/", - { - onRequest: [authenticated, checkUserBan], - preHandler: validateBody(commentVoteRequestSchema), - config: { - rateLimit: { - max: 20, - timeWindow: "1 minute" - } - } - }, - async (request, reply) => { - const { type } = request.body; - - // Ensure user is authenticated - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - const commentId = request.params.id; - - try { - const comment = await Comment.findById(commentId); - - if (!comment) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Comment not found" }); - } - - // Check if user already voted on this comment - let existingVote = await UserVote.findOne({ - votedOn: commentId, - author: userId - }); - - // Handle vote toggle/update - if (existingVote && existingVote.type === type) { - await existingVote.deleteOne(); - } else if (existingVote) { - existingVote.type = type; - await existingVote.save(); - } else { - await UserVote.create({ - type, - votedOn: commentId, - author: userId, - createdAt: new Date() - }); - } - - const voteCounts = await UserVote.aggregate([ - { - $match: { votedOn: commentId } - }, - { - $group: { - _id: null, - upvote: { - $sum: { - $cond: [{ $eq: ["$type", voteTypeEnum.UPVOTE] }, 1, 0] - } - }, - downvote: { - $sum: { - $cond: [{ $eq: ["$type", voteTypeEnum.DOWNVOTE] }, 1, 0] - } - } - } - } - ]); - - // Extract counts from aggregation result - const { upvote = 0, downvote = 0 } = voteCounts[0] || {}; - - comment.upvote = upvote; - comment.downvote = downvote; - - await comment.save(); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(comment); - } catch (error) { - fastify.log.error(error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Internal server error" }); - } - } - ); -} diff --git a/libs/backend/src/routes/execute/index.ts b/libs/backend/src/routes/execute/index.ts deleted file mode 100644 index 6f64e5c1..00000000 --- a/libs/backend/src/routes/execute/index.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - arePistonRuntimes, - ERROR_MESSAGES, - ErrorResponse, - httpResponseCodes, - isFetchError, - isPistonExecutionResponseSuccess, - PistonExecutionRequest, - PistonExecutionResponse, - ExecuteAPI -} from "types"; -import { findRuntime } from "@/utils/functions/findRuntimeInfo.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { calculateResults } from "@/utils/functions/calculate-result.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; - -export const executionResponseErrors = { - UNSUPPORTED_LANGUAGE: { - error: "Unsupported language", - message: "At the moment we don't support this language." - }, - SERVICE_UNAVAILABLE: { - error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, - message: ERROR_MESSAGES.FETCH.NETWORK_ERROR - }, - INTERNAL_SERVER_ERROR: { - error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, - message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG - }, - PISTON_ERROR: { - error: "Piston error" - } -} as const; - -export default async function executeRoutes(fastify: FastifyInstance) { - /** - * POST /execute - Execute code without creating a submission - * Uses specific ExecuteAPI types - */ - fastify.post<{ Body: ExecuteAPI.ExecuteCodeRequest }>( - "/", - { - onRequest: [authenticated, checkUserBan], - preHandler: validateBody(ExecuteAPI.executeCodeRequestSchema), - config: { - rateLimit: { - max: 30, - timeWindow: "1 minute" - } - } - }, - async (request, reply) => { - const { code, language, testInput, testOutput } = request.body; - - const runtimes = await fastify.runtimes(); - - if (!arePistonRuntimes(runtimes)) { - const error: ErrorResponse = runtimes; - - return reply - .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE) - .send(error); - } - - const runtimeInfo = findRuntime(runtimes, language); - - if (!runtimeInfo) { - const error: ErrorResponse = - executionResponseErrors.UNSUPPORTED_LANGUAGE; - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send(error); - } - - const requestObject: PistonExecutionRequest = { - language: runtimeInfo.language, - version: runtimeInfo.version, - files: [{ content: code }], - stdin: testInput - }; - - let executionRes: PistonExecutionResponse; - try { - executionRes = await fastify.piston(requestObject); - } catch (err: unknown) { - request.log.error( - { - err, - requestBody: request.body - }, - "Error during code execution" - ); - - if (isFetchError(err) && err.cause?.code === "ECONNREFUSED") { - const error: ErrorResponse = - executionResponseErrors.SERVICE_UNAVAILABLE; - return reply - .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE) - .send(error); - } - - const error: ErrorResponse = - executionResponseErrors.INTERNAL_SERVER_ERROR; - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(error); - } - - if (!isPistonExecutionResponseSuccess(executionRes)) { - const error: ErrorResponse = { - error: executionResponseErrors.PISTON_ERROR.error, - message: executionRes.message - }; - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(error); - } - - const codeExecutionResponse: ExecuteAPI.ExecuteCodeResponse = { - run: executionRes.run, - compile: executionRes.compile, - puzzleResultInformation: calculateResults([testOutput], [executionRes]) - }; - - return reply - .status(httpResponseCodes.SUCCESSFUL.OK) - .send(codeExecutionResponse); - } - ); -} diff --git a/libs/backend/src/routes/game/leaderboard/index.ts b/libs/backend/src/routes/game/leaderboard/index.ts deleted file mode 100644 index b1bec370..00000000 --- a/libs/backend/src/routes/game/leaderboard/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { httpResponseCodes } from "types"; -import { gameService } from "@/services/game.service.js"; -import { gameModeService } from "@/services/game-mode.service.js"; -import Submission from "@/models/submission/submission.js"; - -/** - * Game leaderboard and statistics routes - */ -export default async function gameLeaderboardRoutes(fastify: FastifyInstance) { - /** - * GET /game/:id/leaderboard - Get ranked leaderboard for a game - */ - fastify.get<{ Params: { id: string } }>( - "/:id/leaderboard", - async (request, reply) => { - try { - const { id } = request.params; - - const game = await gameService.findByIdPopulated(id); - - if (!game) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: `Game with id ${id} not found` }); - } - - // Fetch all submissions for this game - const submissionIds = (game.playerSubmissions ?? []).map((sub) => - typeof sub === "string" ? sub : sub._id - ); - - const submissions = await Submission.find({ - _id: { $in: submissionIds } - }) - .populate("user") - .populate("programmingLanguage") - .exec(); - - // Build leaderboard using game mode service - const leaderboard = gameModeService.getGameLeaderboard( - game, - submissions - ); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - gameId: id, - mode: game.options?.mode, - leaderboard, - totalPlayers: leaderboard.length - }); - } catch (error) { - fastify.log.error(error, "Error fetching game leaderboard"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch leaderboard" }); - } - } - ); - - /** - * GET /game/:id/stats - Get game statistics - */ - fastify.get<{ Params: { id: string } }>( - "/:id/stats", - async (request, reply) => { - try { - const { id } = request.params; - - const game = await gameService.findByIdPopulated(id); - - if (!game) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: `Game with id ${id} not found` }); - } - - const mode = game.options?.mode; - const displayMetrics = mode - ? gameModeService.getDisplayMetricsForMode(mode) - : ["score", "time"]; - - const description = mode - ? gameModeService.getGameModeDescription(mode) - : "Standard game"; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - gameId: id, - mode, - description, - displayMetrics, - playerCount: game.players?.length ?? 0, - submissionCount: game.playerSubmissions?.length ?? 0, - createdAt: game.createdAt, - options: game.options - }); - } catch (error) { - fastify.log.error(error, "Error fetching game stats"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch game statistics" }); - } - } - ); -} diff --git a/libs/backend/src/routes/health/index.ts b/libs/backend/src/routes/health/index.ts deleted file mode 100644 index 961f081c..00000000 --- a/libs/backend/src/routes/health/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; - -export const healthResponse = "OK"; - -export default async function healthRoutes(fastify: FastifyInstance) { - fastify.get("/", async (request: FastifyRequest, reply: FastifyReply) => { - reply.send({ status: healthResponse }); - }); -} diff --git a/libs/backend/src/routes/index.ts b/libs/backend/src/routes/index.ts deleted file mode 100644 index 48af445a..00000000 --- a/libs/backend/src/routes/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FastifyInstance } from "fastify"; - -export default async function indexRoutes(_fastify: FastifyInstance) { - // No routes defined yet -} diff --git a/libs/backend/src/routes/leaderboard/[gameMode]/index.ts b/libs/backend/src/routes/leaderboard/[gameMode]/index.ts deleted file mode 100644 index e62e8436..00000000 --- a/libs/backend/src/routes/leaderboard/[gameMode]/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { genericReturnMessages } from "@/config/generic-return-messages.js"; -import { leaderboardService } from "@/services/leaderboard.service.js"; -import { FastifyInstance } from "fastify"; -import { - DEFAULT_PAGE, - ERROR_MESSAGES, - httpResponseCodes, - isGameMode, - LeaderboardAPI, - PAGINATION_CONFIG -} from "types"; - -const LEADERBOARD = "Leaderboard"; - -export default async function leaderboardByGameModeRoutes( - fastify: FastifyInstance -) { - fastify.get<{ - Params: { gameMode: string }; - Querystring: { page?: string; pageSize?: string }; - }>("/", async (request, reply) => { - const { gameMode } = request.params; - - const page = Math.max( - parseInt(request.query.page || String(DEFAULT_PAGE), 10), - PAGINATION_CONFIG.MIN_PAGE - ); - const pageSize = Math.min( - Math.max( - parseInt( - request.query.pageSize || - String(PAGINATION_CONFIG.DEFAULT_LIMIT_LEADERBOARD), - 10 - ), - PAGINATION_CONFIG.MIN_LIMIT - ), - PAGINATION_CONFIG.MAX_LIMIT - ); - - if (!isGameMode(gameMode)) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `Game mode ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.BAD_REQUEST].IS_INVALID}.` - }); - } - - try { - const result = await leaderboardService.getLeaderboard( - gameMode, - page, - pageSize - ); - - const response: LeaderboardAPI.GetLeaderboardResponse = { - gameMode: gameMode, - entries: result.entries, - page, - pageSize, - totalEntries: result.total, - totalPages: Math.ceil(result.total / pageSize), - lastUpdated: result.lastUpdated.toISOString() - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - request.log.error( - { err: error }, - `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} leaderboard` - ); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: `${LEADERBOARD} ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR].WENT_WRONG}` - }); - } - }); -} diff --git a/libs/backend/src/routes/leaderboard/recalculate/index.ts b/libs/backend/src/routes/leaderboard/recalculate/index.ts deleted file mode 100644 index 336a6b84..00000000 --- a/libs/backend/src/routes/leaderboard/recalculate/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ERROR_MESSAGES, httpResponseCodes } from "types"; -import { FastifyInstance } from "fastify"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import moderatorOnly from "@/plugins/middleware/moderator-only.js"; -import { leaderboardService } from "@/services/leaderboard.service.js"; - -export default async function recalculateLeaderboardRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: [authenticated, moderatorOnly] - }, - async (request, reply) => { - try { - const result = await leaderboardService.recalculateAllLeaderboards(); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - message: `Successfully recalculated leaderboards`, - processed: result - }); - } catch (error) { - fastify.log.error(error, "Failed to recalculate leaderboards"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, - message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG - }); - } - } - ); -} diff --git a/libs/backend/src/routes/leaderboard/user/[id]/index.ts b/libs/backend/src/routes/leaderboard/user/[id]/index.ts deleted file mode 100644 index 35325aef..00000000 --- a/libs/backend/src/routes/leaderboard/user/[id]/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { genericReturnMessages } from "@/config/generic-return-messages.js"; -import User from "@/models/user/user.js"; -import { leaderboardService } from "@/services/leaderboard.service.js"; -import { FastifyInstance } from "fastify"; -import { ERROR_MESSAGES, httpResponseCodes, LeaderboardAPI } from "types"; - -const USER = "User"; - -export default async function leaderboardUserByIdRoutes( - fastify: FastifyInstance -) { - fastify.get<{ Params: { id: string } }>("/", async (request, reply) => { - const { id } = request.params; - - try { - const user = await User.findById(id); - if (!user) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: `${USER} ${genericReturnMessages[httpResponseCodes.CLIENT_ERROR.NOT_FOUND].COULD_NOT_BE_FOUND}` - }); - } - - const rankings = await leaderboardService.getUserRankings(id); - - const response: LeaderboardAPI.GetUserLeaderboardStatsResponse = { - userId: id, - username: user.username, - rankings - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - request.log.error( - { err: error }, - `${ERROR_MESSAGES.FETCH.FAILED_TO_FETCH} user rankings` - ); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: `User rankings ${genericReturnMessages[httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR]}` - }); - } - }); -} diff --git a/libs/backend/src/routes/login/index.ts b/libs/backend/src/routes/login/index.ts deleted file mode 100644 index 475916ce..00000000 --- a/libs/backend/src/routes/login/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { FastifyInstance } from "fastify"; -import bcrypt from "bcryptjs"; -import User from "../../models/user/user.js"; -import { generateToken } from "../../utils/functions/generate-token.js"; -import { - AuthenticatedInfo, - cookieKeys, - environment, - ERROR_MESSAGES, - getCookieOptions, - httpResponseCodes, - isEmail, - loginSchema -} from "types"; - -export default async function loginRoutes(fastify: FastifyInstance) { - fastify.post( - "/", - { - config: { - rateLimit: { - max: 5, - timeWindow: "1 minute" - } - } - }, - async (request, reply) => { - const parseResult = loginSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS }); - } - - const { identifier, password } = parseResult.data; - - try { - const user = isEmail(identifier) - ? await User.findOne({ email: identifier }).select("+password") - : await User.findOne({ username: identifier }) - .select("+password") - .exec(); - - if (!user) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ - message: ERROR_MESSAGES.AUTHENTICATION.INVALID_CREDENTIALS - }); - } - const isMatch = await bcrypt.compare(password, user.password); - - if (!isMatch) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ - message: ERROR_MESSAGES.AUTHENTICATION.INVALID_CREDENTIALS - }); - } - - const authenticatedUserInfo: AuthenticatedInfo = { - userId: String(user._id), - username: user.username, - role: user.role, - isAuthenticated: true - }; - const token = generateToken(fastify, authenticatedUserInfo); - const maxAge = 7 * 24 * 60 * 60; - const isProduction = process.env.NODE_ENV === environment.PRODUCTION; - - const cookieOptions = getCookieOptions({ - isProduction, - ...(process.env.FRONTEND_HOST && { - frontendHost: process.env.FRONTEND_HOST - }), - maxAge - }); - - return reply - .status(httpResponseCodes.SUCCESSFUL.OK) - .setCookie(cookieKeys.TOKEN, token, cookieOptions) - .send({ message: "Login successful" }); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: error }); - } - } - ); -} diff --git a/libs/backend/src/routes/logout/index.ts b/libs/backend/src/routes/logout/index.ts deleted file mode 100644 index 650ae7f9..00000000 --- a/libs/backend/src/routes/logout/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { cookieKeys, environment, getCookieOptions } from "types"; - -export default async function logoutRoutes(fastify: FastifyInstance) { - fastify.post("/", async (request, reply) => { - try { - const isProduction = process.env.NODE_ENV === environment.PRODUCTION; - - const cookieOptions = getCookieOptions({ - isProduction, - ...(process.env.FRONTEND_HOST && { - frontendHost: process.env.FRONTEND_HOST - }) - }); - - // Clear the cookie using Fastify's clearCookie method - reply.clearCookie(cookieKeys.TOKEN, cookieOptions); - - return reply.status(200).send({ message: "Logout successful" }); - } catch (error) { - console.error("Logout error:", error); - return reply.status(500).send({ message: "Logout failed" }); - } - }); -} diff --git a/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts b/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts deleted file mode 100644 index dab952b6..00000000 --- a/libs/backend/src/routes/moderation/puzzle/[id]/approve/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - puzzleVisibilityEnum, - approvePuzzleSchema -} from "types"; -import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js"; -import Puzzle from "../../../../../models/puzzle/puzzle.js"; -import { ParamsId } from "../../../../../types/types.js"; - -export default async function moderationPuzzleByIdApproveRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = approvePuzzleSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - try { - const puzzle = await Puzzle.findById(id); - - if (!puzzle) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Puzzle not found" }); - } - - // Update puzzle to approved status - puzzle.visibility = puzzleVisibilityEnum.APPROVED; - puzzle.updatedAt = new Date(); - await puzzle.save(); - - return reply.send({ - message: "Puzzle approved successfully", - puzzle - }); - } catch (error) { - fastify.log.error(error, "Failed to approve puzzle"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to approve puzzle" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts b/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts deleted file mode 100644 index 7dbef2e3..00000000 --- a/libs/backend/src/routes/moderation/puzzle/[id]/revise/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - puzzleVisibilityEnum, - revisePuzzleSchema -} from "types"; -import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js"; -import Puzzle from "../../../../../models/puzzle/puzzle.js"; -import { ParamsId } from "../../../../../types/types.js"; - -export default async function moderationPuzzleByIdReviseRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = revisePuzzleSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - const { reason } = parseResult.data; - - try { - const puzzle = await Puzzle.findById(id); - - if (!puzzle) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Puzzle not found" }); - } - - puzzle.visibility = puzzleVisibilityEnum.REVISE; - puzzle.moderationFeedback = reason; - puzzle.updatedAt = new Date(); - await puzzle.save(); - - return reply.send({ - message: "Puzzle sent back for revisions", - puzzle - }); - } catch (error) { - fastify.log.error(error, "Failed to request puzzle revisions"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to request puzzle revisions" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts b/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts deleted file mode 100644 index dcaeb1f4..00000000 --- a/libs/backend/src/routes/moderation/report/[id]/resolve/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - resolveReportSchema, - reviewStatusEnum, - ProblemTypeEnum -} from "types"; -import mongoose from "mongoose"; -import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js"; -import Report from "../../../../../models/report/report.js"; -import { ParamsId } from "../../../../../types/types.js"; -import { - incrementReportCount, - applyAutomaticEscalation -} from "../../../../../utils/moderation/escalation.js"; -import Comment from "../../../../../models/comment/comment.js"; -import Puzzle from "../../../../../models/puzzle/puzzle.js"; -import ChatMessage from "../../../../../models/chat/chat-message.js"; - -export default async function moderationReportByIdResolveRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = resolveReportSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - - try { - const report = await Report.findById(id); - - if (!report) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Report not found" }); - } - - // Update report status - report.status = parseResult.data.status; - report.resolvedBy = new mongoose.Types.ObjectId(userId); - report.updatedAt = new Date(); - await report.save(); - - // If report is resolved, increment user's report count and check for escalation - if (parseResult.data.status === reviewStatusEnum.RESOLVED) { - try { - // Find the reported entity to get the user ID - let reportedUserId = null; - - if (report.problemType === ProblemTypeEnum.USER) { - reportedUserId = report.problematicIdentifier; - } else { - // For other types, we need to find the author/owner directly - // This could be a puzzle author, comment author, or chat message sender - // We'll query the specific model based on problem type - if (report.problemType === ProblemTypeEnum.PUZZLE) { - const puzzle = await Puzzle.findById( - report.problematicIdentifier - ); - reportedUserId = puzzle?.author; - } else if (report.problemType === ProblemTypeEnum.COMMENT) { - const comment = await Comment.findById( - report.problematicIdentifier - ); - reportedUserId = comment?.author; - } else if (report.problemType === ProblemTypeEnum.GAME_CHAT) { - const message = await ChatMessage.findById( - report.problematicIdentifier - ); - reportedUserId = message?.userId; - } - } - - if (reportedUserId) { - // Increment report count - const reportCount = await incrementReportCount( - reportedUserId.toString() - ); - - // Apply automatic escalation if needed - const ban = await applyAutomaticEscalation( - reportedUserId.toString(), - userId, - report.explanation - ); - - if (ban) { - fastify.log.info( - { - userId: reportedUserId, - reportCount, - banType: ban.banType - }, - "Automatic ban applied" - ); - } - } - } catch (escalationError) { - fastify.log.error( - escalationError, - "Failed to apply escalation, but report was resolved" - ); - } - } - - return reply.send({ - message: "Report resolved successfully", - report - }); - } catch (error) { - fastify.log.error(error, "Failed to resolve report"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to resolve report" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/review/index.ts b/libs/backend/src/routes/moderation/review/index.ts deleted file mode 100644 index fc9a8fb5..00000000 --- a/libs/backend/src/routes/moderation/review/index.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - reviewItemTypeEnum, - reviewStatusEnum, - puzzleVisibilityEnum, - ReviewItem, - DEFAULT_PAGE, - DEFAULT_PAGE_SIZE, - BAN_CONFIG, - ProblemTypeEnum -} from "types"; -import moderatorOnly from "../../../plugins/middleware/moderator-only.js"; -import Puzzle from "../../../models/puzzle/puzzle.js"; -import Report from "../../../models/report/report.js"; -import ChatMessage from "../../../models/chat/chat-message.js"; - -export default async function moderationReviewRoutes(fastify: FastifyInstance) { - // Get review items (pending puzzles or reports) - fastify.get( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const query = request.query as { - type?: string; - page?: string; - limit?: string; - }; - - const type = query.type || reviewItemTypeEnum.PENDING_PUZZLE; - const page = Number.parseInt(query.page || String(DEFAULT_PAGE), 10); - const limit = Number.parseInt( - query.limit || String(DEFAULT_PAGE_SIZE), - 10 - ); - const skip = (page - 1) * limit; - - try { - let items: ReviewItem[] = []; - let total = 0; - - if (type === reviewItemTypeEnum.PENDING_PUZZLE) { - // Get puzzles that are ready for review - const puzzles = await Puzzle.find({ - visibility: puzzleVisibilityEnum.READY - }) - .populate("author", "username") - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit); - - total = await Puzzle.countDocuments({ - visibility: puzzleVisibilityEnum.READY - }); - - items = puzzles.map((puzzle: any) => ({ - id: puzzle._id.toString(), - type: reviewItemTypeEnum.PENDING_PUZZLE, - title: puzzle.title, - description: puzzle.statement, - createdAt: puzzle.createdAt || new Date(), - authorName: - typeof puzzle.author === "object" && - puzzle.author && - "username" in puzzle.author - ? String(puzzle.author.username) - : undefined - })); - } else { - // Get reports filtered by type - const filter: any = { status: reviewStatusEnum.PENDING }; - - if (type === reviewItemTypeEnum.REPORTED_PUZZLE) { - filter.problemType = ProblemTypeEnum.PUZZLE; - } else if (type === reviewItemTypeEnum.REPORTED_USER) { - filter.problemType = ProblemTypeEnum.USER; - } else if (type === reviewItemTypeEnum.REPORTED_COMMENT) { - filter.problemType = ProblemTypeEnum.COMMENT; - } else if (type === reviewItemTypeEnum.REPORTED_GAME_CHAT) { - filter.problemType = ProblemTypeEnum.GAME_CHAT; - } - - const reports = await Report.find(filter) - .populate("reportedBy", "username") - .populate("problematicIdentifier") - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit); - - total = await Report.countDocuments(filter); - - items = await Promise.all( - reports.map(async (report: any) => { - let title = "Unknown"; - let description = ""; - let gameId; - let contextMessages; - - // Get title based on problem type - if (report.problemType === ProblemTypeEnum.PUZZLE) { - const puzzle = report.problematicIdentifier; - title = puzzle?.title || "Deleted Puzzle"; - description = puzzle?.statement || ""; - } else if (report.problemType === ProblemTypeEnum.USER) { - const user = report.problematicIdentifier; - title = user?.username || "Deleted User"; - } else if (report.problemType === ProblemTypeEnum.COMMENT) { - const comment = report.problematicIdentifier; - title = `Comment: ${comment?.text?.substring(0, 50) || "Deleted Comment"}`; - description = comment?.text || ""; - } else if (report.problemType === ProblemTypeEnum.GAME_CHAT) { - const chatMessage = report.problematicIdentifier; - title = `Chat from ${chatMessage?.username || "Unknown"}`; - description = chatMessage?.message || "Deleted Message"; - gameId = chatMessage?.gameId; - - // Get context messages (5 before and 5 after) - if (chatMessage && chatMessage.gameId) { - const allMessages = await ChatMessage.find({ - gameId: chatMessage.gameId - }) - .sort({ createdAt: 1 }) - .exec(); - - const reportedIndex = allMessages.findIndex( - (msg) => - String(msg._id) === - report.problematicIdentifier.toString() - ); - - if (reportedIndex !== -1) { - const startIndex = Math.max( - 0, - reportedIndex - - BAN_CONFIG.chatRetention.CONTEXT_MESSAGES_BEFORE - ); - const endIndex = Math.min( - allMessages.length, - reportedIndex + - BAN_CONFIG.chatRetention.CONTEXT_MESSAGES_AFTER + - 1 - ); - - contextMessages = allMessages - .slice(startIndex, endIndex) - .map((msg) => ({ - _id: msg._id, - username: msg.username, - message: msg.message, - createdAt: msg.createdAt, - isReported: - String(msg._id) === - report.problematicIdentifier.toString() - })); - } - } - } - - return { - id: report._id.toString(), - type: type as (typeof reviewItemTypeEnum)[keyof typeof reviewItemTypeEnum], - title, - description, - createdAt: report.createdAt || new Date(), - reportExplanation: report.explanation, - reportedBy: - typeof report.reportedBy === "object" && - report.reportedBy && - "username" in report.reportedBy - ? String(report.reportedBy.username) - : undefined, - gameId, - contextMessages - }; - }) - ); - } - - const response = { - data: items, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit) - } - }; - - return reply.send(response); - } catch (error) { - fastify.log.error(error, "Failed to fetch review items"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch review items" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts deleted file mode 100644 index 67b3ed0b..00000000 --- a/libs/backend/src/routes/moderation/user/[id]/ban/history/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { httpResponseCodes, isAuthenticatedInfo } from "types"; -import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js"; -import { ParamsId } from "../../../../../../types/types.js"; -import UserBan from "../../../../../../models/moderation/user-ban.js"; - -export default async function moderationUserByIdBanHistoryRoutes( - fastify: FastifyInstance -) { - fastify.get( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - try { - const bans = await UserBan.find({ userId: id }) - .populate("bannedBy", "username") - .sort({ createdAt: -1 }) - .exec(); - - return reply.send({ - bans - }); - } catch (error) { - fastify.log.error(error, "Failed to fetch ban history"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch ban history" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts deleted file mode 100644 index f7427288..00000000 --- a/libs/backend/src/routes/moderation/user/[id]/ban/permanent/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - createPermanentBanSchema -} from "types"; -import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js"; -import { ParamsId } from "../../../../../../types/types.js"; -import { createPermanentBan } from "../../../../../../utils/moderation/escalation.js"; - -export default async function moderationUserByIdBanPermanentRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = createPermanentBanSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - try { - const ban = await createPermanentBan( - id, - request.user.userId, - parseResult.data.reason - ); - - return reply.send({ - message: "User permanently banned", - ban - }); - } catch (error) { - fastify.log.error(error, "Failed to ban user"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to ban user" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts b/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts deleted file mode 100644 index edf3d94e..00000000 --- a/libs/backend/src/routes/moderation/user/[id]/ban/temporary/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - isAuthenticatedInfo, - createTemporaryBanSchema -} from "types"; -import moderatorOnly from "../../../../../../plugins/middleware/moderator-only.js"; -import { ParamsId } from "../../../../../../types/types.js"; -import { createTemporaryBan } from "../../../../../../utils/moderation/escalation.js"; - -export default async function moderationUserByIdBanTemporaryRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = createTemporaryBanSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - try { - const ban = await createTemporaryBan( - id, - request.user.userId, - parseResult.data.reason, - parseResult.data.durationMs - ); - - return reply.send({ - message: "User temporarily banned", - ban - }); - } catch (error) { - fastify.log.error(error, "Failed to ban user"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to ban user" }); - } - } - ); -} diff --git a/libs/backend/src/routes/moderation/user/[id]/unban/index.ts b/libs/backend/src/routes/moderation/user/[id]/unban/index.ts deleted file mode 100644 index 98508b1a..00000000 --- a/libs/backend/src/routes/moderation/user/[id]/unban/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { httpResponseCodes, isAuthenticatedInfo, unbanUserSchema } from "types"; -import moderatorOnly from "../../../../../plugins/middleware/moderator-only.js"; -import { ParamsId } from "../../../../../types/types.js"; -import { unbanUser } from "../../../../../utils/moderation/escalation.js"; - -export default async function moderationUserByIdBanUnbanRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: moderatorOnly - }, - async (request, reply) => { - const { id } = request.params; - - const parseResult = unbanUserSchema.safeParse(request.body); - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - try { - await unbanUser(id, request.user.userId, parseResult.data.reason); - - return reply.send({ - message: "User unbanned successfully" - }); - } catch (error) { - fastify.log.error(error, "Failed to unban user"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to unban user" }); - } - } - ); -} diff --git a/libs/backend/src/routes/programming-language/[id]/index.ts b/libs/backend/src/routes/programming-language/[id]/index.ts deleted file mode 100644 index 4fa0bb6e..00000000 --- a/libs/backend/src/routes/programming-language/[id]/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - ProgrammingLanguageDto, - programmingLanguageDtoSchema -} from "types"; -import ProgrammingLanguage from "../../../models/programming-language/language.js"; - -export default async function programmingLanguageByIdRoutes( - fastify: FastifyInstance -) { - // GET programming language by ID - fastify.get("/", async (request, reply) => { - try { - const { id } = request.params as { id: string }; - - const language = await ProgrammingLanguage.findById(id) - .select("-createdAt -updatedAt -__v") - .lean(); - - if (!language) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: "Programming language not found" - }); - } - - const dto: ProgrammingLanguageDto = programmingLanguageDtoSchema.parse({ - _id: language._id.toString(), - language: language.language, - version: language.version, - aliases: language.aliases, - runtime: language.runtime - }); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(dto); - } catch (error) { - fastify.log.error(error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch programming language" }); - } - }); -} diff --git a/libs/backend/src/routes/programming-language/index.ts b/libs/backend/src/routes/programming-language/index.ts deleted file mode 100644 index 710a2487..00000000 --- a/libs/backend/src/routes/programming-language/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - httpResponseCodes, - ProgrammingLanguageDto, - programmingLanguageDtoSchema, - arePistonRuntimes -} from "types"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; - -export default async function programmingLanguageRoutes( - fastify: FastifyInstance -) { - fastify.get("/", async (_, reply) => { - try { - // Get available runtimes from Piston - const runtimes = await fastify.runtimes(); - - if (!arePistonRuntimes(runtimes)) { - fastify.log.error("Failed to fetch Piston runtimes"); - return reply - .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE) - .send({ error: "Code execution service is unavailable" }); - } - - // Create a set of available language+version combinations - const availableLanguages = new Set( - runtimes.map((runtime) => `${runtime.language}:${runtime.version}`) - ); - - // Fetch all programming languages from database - const allLanguages = await ProgrammingLanguage.find() - .select("-createdAt -updatedAt -__v") - .sort({ language: 1, version: -1 }) - .lean(); - - // Filter to only include languages that are available in Piston - const installedLanguages = allLanguages.filter((lang) => - availableLanguages.has(`${lang.language}:${lang.version}`) - ); - - const dtos: ProgrammingLanguageDto[] = installedLanguages.map((lang) => - programmingLanguageDtoSchema.parse({ - _id: lang._id.toString(), - language: lang.language, - version: lang.version, - aliases: lang.aliases, - runtime: lang.runtime - }) - ); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - languages: dtos - }); - } catch (error) { - fastify.log.error(error); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch programming languages" }); - } - }); -} diff --git a/libs/backend/src/routes/puzzle/[id]/comment/index.ts b/libs/backend/src/routes/puzzle/[id]/comment/index.ts deleted file mode 100644 index c9935285..00000000 --- a/libs/backend/src/routes/puzzle/[id]/comment/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Comment from "@/models/comment/comment.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { ParamsId } from "@/types/types.js"; -import { FastifyInstance } from "fastify"; -import { - CommentEntity, - commentTypeEnum, - createCommentSchema, - httpResponseCodes, - isAuthenticatedInfo -} from "types"; - -export default async function puzzleByIdCommentRoutes( - fastify: FastifyInstance -) { - fastify.post( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const parseResult = createCommentSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const id = request.params.id; - const user = request.user; - const userId = user.userId; - - const commentData: CommentEntity = { - ...parseResult.data, - author: userId, - upvote: 0, - downvote: 0, - comments: [], - commentType: commentTypeEnum.PUZZLE, - parentId: id - }; - try { - const newComment = new Comment(commentData); - await newComment.save(); - - await Puzzle.findByIdAndUpdate( - id, - { $push: { comments: newComment._id } }, - { new: true } - ); - - const comment = await Comment.findById(newComment.id).populate( - "author" - ); - - return reply.status(httpResponseCodes.SUCCESSFUL.CREATED).send(comment); - } catch (error) { - request.log.error({ err: error }, "Failed to create comment"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to create comment" }); - } - } - ); -} diff --git a/libs/backend/src/routes/puzzle/[id]/index.ts b/libs/backend/src/routes/puzzle/[id]/index.ts deleted file mode 100644 index 7803e9bd..00000000 --- a/libs/backend/src/routes/puzzle/[id]/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - AuthenticatedInfo, - DeletePuzzle, - ERROR_MESSAGES, - ErrorResponse, - httpResponseCodes, - isAuthor, - isAuthenticatedInfo, - isModerator, - isPuzzleDto, - PUZZLE_CONFIG, - PuzzleAPI, - puzzleVisibilityEnum, - type PuzzleVisibility -} from "types"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import User from "@/models/user/user.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { ParamsId } from "@/types/types.js"; -import { checkAllValidators } from "@/utils/functions/check-all-validators.js"; - -export default async function puzzleByIdRoutes(fastify: FastifyInstance) { - fastify.get("/", async (request, reply) => { - const { id } = request.params; - - try { - const puzzle = await Puzzle.findById(id) - .populate("author") - .populate("comments") - .populate({ - path: "comments", - populate: { - path: "author" - } - }); - - if (!puzzle) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Puzzle not found" }); - } - - return reply.send(puzzle); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch puzzle" }); - } - }); - - fastify.put( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const { id } = request.params; - const parseResult = PuzzleAPI.updatePuzzleRequestSchema - .omit({ id: true }) - .safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - const user = request.user; - - if (!isAuthenticatedInfo(user)) { - const errorResponse: ErrorResponse = { - error: "Missing credentials", - message: "You need to be logged in." - }; - - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send(errorResponse); - } - - const userId = user.userId; - - try { - const puzzle = await Puzzle.findById(id).lean(); - - if (!puzzle) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: ERROR_MESSAGES.PUZZLE.NOT_FOUND }); - } - - const user = await User.findById(userId); - - const authorIdString = puzzle.author ? String(puzzle.author) : null; - const isAuthorCheck = - authorIdString !== null && isAuthor(authorIdString, userId); - - if (!isAuthorCheck && !isModerator(user?.role)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN) - .send({ error: "Not authorized to edit this puzzle" }); - } - if ( - parseResult.data.visibility === puzzleVisibilityEnum.APPROVED && - !isModerator(user?.role) - ) { - return reply.status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN).send({ - error: "Only moderators can approve puzzles", - message: - "You cannot set your own puzzle to approved status. Submit it for review instead." - }); - } - - const updatedPuzzle = await Puzzle.findByIdAndUpdate( - id, - parseResult.data, - { new: true } - ); - - const checkWhenEdited: PuzzleVisibility[] = [ - puzzleVisibilityEnum.DRAFT, - puzzleVisibilityEnum.READY, - puzzleVisibilityEnum.REVIEW - ]; - - if ( - updatedPuzzle && - checkWhenEdited.includes(updatedPuzzle.visibility) && - updatedPuzzle.validators && - updatedPuzzle.validators.length >= - PUZZLE_CONFIG.requiredNumberOfValidators && - isPuzzleDto(updatedPuzzle) - ) { - try { - const allPassed = await checkAllValidators(updatedPuzzle, fastify); - - if (allPassed) { - updatedPuzzle.visibility = puzzleVisibilityEnum.READY; - } else { - updatedPuzzle.visibility = puzzleVisibilityEnum.DRAFT; - } - - await updatedPuzzle.save(); - } catch (error) { - request.log.error(error, "Failed to check validators"); - } - } - - return reply.send(updatedPuzzle); - } catch (error) { - const errorResponse: ErrorResponse = { - error: ERROR_MESSAGES.PUZZLE.FAILED_TO_UPDATE, - message: "" - }; - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send(errorResponse); - } - } - ); - - fastify.delete<{ Params: DeletePuzzle }>( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const { id } = request.params; - - const user: AuthenticatedInfo = request.user as AuthenticatedInfo; - const userId = user.userId; - - try { - const puzzle = await Puzzle.findById(id); - - if (!puzzle) { - const error: ErrorResponse = { - error: "Puzzle not found", - message: `Couldn't find puzzle with id (${id})` - }; - - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send(error); - } - - const isAuthorOfPuzzle = isAuthor(puzzle.author.toString(), userId); - const isNotAuthorOfPuzzle = !isAuthorOfPuzzle; - - if (isNotAuthorOfPuzzle) { - return reply - .status(403) - .send({ error: "Not authorized to delete this puzzle" }); - } - - const allowedToRemoveState: PuzzleVisibility[] = [ - puzzleVisibilityEnum.DRAFT, - puzzleVisibilityEnum.READY - ]; - - const isDraft = allowedToRemoveState.includes(puzzle.visibility); - const isNotDraft = !isDraft; - if (isNotDraft) { - // TODO: figure out: this is a questionable choice at the moment, but might not want to delete an interesting puzzle completely which users already have solved, so maybe archive instead of a full delete?? - return reply.status(403).send({ - error: "This puzzle was public, contact support to get it deleted." - }); - } - - await puzzle.deleteOne(); - - return reply.status(204).send(); - } catch (error) { - return reply.status(500).send({ error: "Failed to delete puzzle" }); - } - } - ); -} diff --git a/libs/backend/src/routes/puzzle/[id]/solution/index.ts b/libs/backend/src/routes/puzzle/[id]/solution/index.ts deleted file mode 100644 index 53e4d2d9..00000000 --- a/libs/backend/src/routes/puzzle/[id]/solution/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - ErrorResponse, - getUserIdFromUser, - httpResponseCodes, - isAuthenticatedInfo, - isAuthor, - isModerator -} from "types"; -import { ParamsId } from "@/types/types.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import User from "@/models/user/user.js"; - -export default async function puzzleByIdSolutionRoutes( - fastify: FastifyInstance -) { - fastify.get( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const { id } = request.params; - - const user = request.user; - - if (!isAuthenticatedInfo(user)) { - const errorResponse: ErrorResponse = { - error: "Missing credentials", - message: "You need to be logged in." - }; - - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send(errorResponse); - } - - const userId = user.userId; - - try { - const puzzle = await Puzzle.findById(id) - .select("+solution") - .populate("author") - .populate("solution.programmingLanguage") - .lean(); - - if (!puzzle) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Puzzle not found" }); - } - - const user = await User.findById(userId); - - const authorIdString = getUserIdFromUser(puzzle.author); - const isAuthorCheck = - authorIdString !== null && isAuthor(authorIdString, userId); - - const lacksRequiredPermissions = - !isAuthorCheck && !isModerator(user?.role); - - if (lacksRequiredPermissions) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.FORBIDDEN) - .send({ error: "Not authorized" }); - } - - return reply.send(puzzle); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch puzzle" }); - } - } - ); -} diff --git a/libs/backend/src/routes/puzzle/index.ts b/libs/backend/src/routes/puzzle/index.ts deleted file mode 100644 index cb573e83..00000000 --- a/libs/backend/src/routes/puzzle/index.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - DEFAULT_PAGE, - httpResponseCodes, - isAuthenticatedInfo, - PuzzleAPI, - type CreatePuzzleBackend -} from "types"; -import Puzzle from "../../models/puzzle/puzzle.js"; -import authenticated from "../../plugins/middleware/authenticated.js"; -import checkUserBan from "../../plugins/middleware/check-user-ban.js"; - -export default async function puzzleRoutes(fastify: FastifyInstance) { - fastify.post( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const parseResult = PuzzleAPI.createPuzzleRequestSchema.safeParse( - request.body - ); - - if (!parseResult.success) { - return reply.status(400).send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply.status(401).send({ error: "Invalid credentials" }); - } - - const user = request.user; - const userId = user.userId; - - const puzzleData: CreatePuzzleBackend = { - ...parseResult.data, - author: userId - }; - - try { - const puzzle = new Puzzle(puzzleData); - await puzzle.save(); - - return reply.status(201).send(puzzle); - } catch (error) { - return reply.status(500).send({ error: "Failed to create puzzle" }); - } - } - ); - - fastify.get("/", async (request, reply) => { - const parseResult = PuzzleAPI.getPuzzlesRequestSchema.safeParse( - request.query - ); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - const query = parseResult.data; - const { page, pageSize } = query; - - try { - const offsetSkip = (page - DEFAULT_PAGE) * pageSize; - - const [puzzles, total] = await Promise.all([ - Puzzle.find() - .populate("author") - .skip(offsetSkip) - .limit(pageSize) - .lean() - .exec(), - Puzzle.countDocuments() - ]); - - const totalPages = Math.ceil(total / pageSize); - - const paginatedResponse = { - items: puzzles, - page, - pageSize, - totalItems: total, - totalPages - }; - - return reply.send(paginatedResponse); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch puzzles" }); - } - }); -} diff --git a/libs/backend/src/routes/register/index.ts b/libs/backend/src/routes/register/index.ts deleted file mode 100644 index 937886eb..00000000 --- a/libs/backend/src/routes/register/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { z } from "zod"; -import { Error } from "mongoose"; -import { MongoError } from "mongodb"; -import User from "../../models/user/user.js"; -import { - cookieKeys, - environment, - ERROR_MESSAGES, - getCookieOptions, - httpResponseCodes, - registerSchema -} from "types"; -import { generateToken } from "../../utils/functions/generate-token.js"; - -export default async function registerRoutes(fastify: FastifyInstance) { - fastify.post( - "/", - { - config: { - rateLimit: { - max: 3, - timeWindow: "15 minutes" - } - } - }, - async (request, reply) => { - let parsedBody; - try { - parsedBody = registerSchema.parse(request.body); - } catch (error) { - if (error instanceof z.ZodError) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: error.issues }); - } - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: ERROR_MESSAGES.SERVER.INTERNAL_ERROR }); - } - - const { email, password, username } = parsedBody; - - try { - const existingUserByUsername = await User.findOne({ username }); - if (existingUserByUsername) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: "Username already exists" }); - } - - const existingUserByEmail = await User.findOne({ email }); - if (existingUserByEmail) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: "Email already exists" }); - } - - const user = new User({ email, password, username }); - await user.save(); - - const authenticatedUserInfo = { - userId: `${user._id}`, - username: user.username, - role: user.role, - isAuthenticated: true - }; - const token = generateToken(fastify, authenticatedUserInfo); - const isProduction = process.env.NODE_ENV === environment.PRODUCTION; - - const cookieOptions = getCookieOptions({ - isProduction, - ...(process.env.FRONTEND_HOST && { - frontendHost: process.env.FRONTEND_HOST - }), - maxAge: 7 * 24 * 60 * 60 - }); - - return reply - .status(httpResponseCodes.SUCCESSFUL.OK) - .setCookie(cookieKeys.TOKEN, token, cookieOptions) - .send({ message: "User registered successfully" }); - } catch (error) { - if (error instanceof Error.ValidationError) { - const messages = Object.values(error.errors).map( - (err) => err.message - ); - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS, - error: messages - }); - } else if (error instanceof MongoError && error.code === 11000) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ message: `Duplicate key, unique value already exists` }); - } - - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, error }); - } - } - ); -} diff --git a/libs/backend/src/routes/report/index.ts b/libs/backend/src/routes/report/index.ts deleted file mode 100644 index 9625661e..00000000 --- a/libs/backend/src/routes/report/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - createReportSchema, - httpResponseCodes, - isAuthenticatedInfo, - ReportEntity, - reviewStatusEnum, - ProblemTypeEnum -} from "types"; -import Report from "../../models/report/report.js"; -import authenticated from "../../plugins/middleware/authenticated.js"; -import checkUserBan from "../../plugins/middleware/check-user-ban.js"; -import ChatMessage from "../../models/chat/chat-message.js"; - -export default async function reportRoutes(fastify: FastifyInstance) { - fastify.post( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const parseResult = createReportSchema.safeParse(request.body); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - if (!isAuthenticatedInfo(request.user)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED) - .send({ error: "Invalid credentials" }); - } - - const userId = request.user.userId; - - if (parseResult.data.problemType === ProblemTypeEnum.GAME_CHAT) { - const chatMessage = await ChatMessage.findById( - parseResult.data.problematicIdentifier - ); - - if (!chatMessage) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: "Chat message not found" }); - } - } - - const newReportData: Omit = { - ...parseResult.data, - reportedBy: userId, - status: reviewStatusEnum.PENDING - }; - - try { - const newReport = new Report(newReportData); - await newReport.save(); - - return reply - .status(httpResponseCodes.SUCCESSFUL.CREATED) - .send(newReport); - } catch (error) { - fastify.log.error(error, "Failed to create report"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to create report" }); - } - } - ); -} diff --git a/libs/backend/src/routes/submission/[id]/index.ts b/libs/backend/src/routes/submission/[id]/index.ts deleted file mode 100644 index e7cb3948..00000000 --- a/libs/backend/src/routes/submission/[id]/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Submission from "@/models/submission/submission.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { ParamsId } from "@/types/types.js"; -import { FastifyInstance } from "fastify"; -import { httpResponseCodes } from "types"; - -export default async function submissionByIdRoutes(fastify: FastifyInstance) { - fastify.get( - "/", - { - onRequest: [authenticated, checkUserBan] - }, - async (request, reply) => { - const { id } = request.params; - - try { - const submission = await Submission.findById(id) - .select("+code") - .populate("programmingLanguage"); - - if (!submission) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: "Submission not found" }); - } - - return reply.send(submission); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to fetch submission" }); - } - } - ); -} diff --git a/libs/backend/src/routes/submission/game/index.ts b/libs/backend/src/routes/submission/game/index.ts deleted file mode 100644 index ad4709db..00000000 --- a/libs/backend/src/routes/submission/game/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - getUserIdFromUser, - httpResponseCodes, - isAuthor, - isString, - SUBMISSION_BUFFER_IN_MILLISECONDS, - gameModeEnum, - SubmissionAPI -} from "types"; -import { isValidationError } from "../../../utils/functions/is-validation-error.js"; -import Submission from "@/models/submission/submission.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { gameService } from "@/services/game.service.js"; -import { gameModeService } from "@/services/game-mode.service.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; - -export default async function submissionGameRoutes(fastify: FastifyInstance) { - /** - * POST /submission/game - Submit code to a multiplayer game - * Uses specific SubmissionAPI types - */ - fastify.post<{ Body: SubmissionAPI.SubmitToGameRequest }>( - "/", - { - onRequest: [authenticated, checkUserBan], - preHandler: validateBody(SubmissionAPI.submitToGameRequestSchema) - }, - async (request, reply) => { - const { gameId, submissionId, userId } = request.body; - - try { - const matchingSubmission = await Submission.findById(submissionId) - .populate("programmingLanguage") - .exec(); - - if ( - !matchingSubmission || - getUserIdFromUser(matchingSubmission.user) !== userId - ) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: `couldn't find a submission with id (${submissionId}) belonging to user with id (${userId})` - }); - } - - const matchingGame = await gameService.findByIdPopulated(gameId); - - if (!matchingGame) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ error: `couldn't find a game with id (${gameId})` }); - } - - const gameMode = matchingGame.options?.mode ?? gameModeEnum.FASTEST; - const validation = gameModeService.validateSubmissionForMode(gameMode, { - result: matchingSubmission.result, - attempts: 1 - }); - - if (!validation.valid) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `Submission invalid for ${gameMode} mode`, - reason: validation.reason - }); - } - - const latestSubmissionTime = - new Date(matchingSubmission.createdAt).getTime() + - SUBMISSION_BUFFER_IN_MILLISECONDS; - const currentTime = Date.now(); - const tooFarInThePast = latestSubmissionTime < currentTime; - - if (tooFarInThePast) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `Submission too old for game with id (${gameId})` - }); - } - - const gameHasExistingUserSubmission = - matchingGame.playerSubmissions.find((submission) => { - if (isString(submission)) { - return false; - } - - return isAuthor(getUserIdFromUser(submission.user), userId); - }); - - if (gameHasExistingUserSubmission) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `User ${userId} has already submitted to game ${gameId}` - }); - } - - // Add submission to game - const uniquePlayerSubmissions = new Set([ - ...(matchingGame.playerSubmissions ?? []), - submissionId - ]); - - matchingGame.playerSubmissions = Array.from(uniquePlayerSubmissions); - - const updatedGame = await matchingGame.save(); - - // Calculate leaderboard position - const leaderboard = gameModeService.getGameLeaderboard( - updatedGame, - await Submission.find({ - _id: { $in: updatedGame.playerSubmissions } - }) - .populate("user") - .exec() - ); - - const userPosition = leaderboard.findIndex( - (entry) => entry.userId === userId - ); - - // Build response using specific type - const response: SubmissionAPI.SubmitToGameResponse = { - success: true, - message: "Submission successfully added to game", - game: { - id: ( - updatedGame._id as import("mongoose").Types.ObjectId - ).toString(), - status: "in_progress", // Could be calculated based on game state - playerCount: updatedGame.players?.length ?? 0 - }, - leaderboardPosition: - userPosition !== -1 ? userPosition + 1 : undefined - }; - - return reply - .status(httpResponseCodes.SUCCESSFUL.CREATED) - .send(response); - } catch (error) { - request.log.error({ err: error }, "Error submitting to game"); - - if (isValidationError(error)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: "Validation failed", details: error.errors }); - } - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to submit to game" }); - } - } - ); -} diff --git a/libs/backend/src/routes/submission/index.ts b/libs/backend/src/routes/submission/index.ts deleted file mode 100644 index ddabb14c..00000000 --- a/libs/backend/src/routes/submission/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - ERROR_MESSAGES, - httpResponseCodes, - PistonExecutionResponse, - PistonExecutionRequest, - ErrorResponse, - arePistonRuntimes, - SubmissionAPI -} from "types"; -import mongoose from "mongoose"; -import Submission from "../../models/submission/submission.js"; -import Puzzle, { PuzzleDocument } from "../../models/puzzle/puzzle.js"; -import { isValidationError } from "../../utils/functions/is-validation-error.js"; -import { findRuntime } from "@/utils/functions/findRuntimeInfo.js"; -import authenticated from "@/plugins/middleware/authenticated.js"; -import checkUserBan from "@/plugins/middleware/check-user-ban.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; -import { calculateResults } from "@/utils/functions/calculate-result.js"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; - -export default async function submissionRoutes(fastify: FastifyInstance) { - /** - * POST /submission - Create a new code submission - * Uses specific SubmissionAPI types instead of generic DTOs - */ - fastify.post<{ Body: SubmissionAPI.SubmitCodeRequest }>( - "/", - { - onRequest: [authenticated, checkUserBan], - preHandler: validateBody(SubmissionAPI.submitCodeRequestSchema), - config: { - rateLimit: { - max: 10, - timeWindow: "1 minute" - } - } - }, - async (request, reply) => { - const { programmingLanguageId, puzzleId, code, userId } = request.body; - - // Retrieve puzzle and test cases - const puzzle: PuzzleDocument | null = await Puzzle.findById(puzzleId); - - if (!puzzle) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: ERROR_MESSAGES.PUZZLE.NOT_FOUND - }); - } - - if (!puzzle.validators || puzzle.validators.length === 0) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: ERROR_MESSAGES.PUZZLE.FAILED_TO_UPDATE - }); - } - - // Get piston runtimes - const runtimes = await fastify.runtimes(); - - if (!arePistonRuntimes(runtimes)) { - const error: ErrorResponse = runtimes; - return reply - .status(httpResponseCodes.SERVER_ERROR.SERVICE_UNAVAILABLE) - .send(error); - } - - // Find programming language - const language = await ProgrammingLanguage.findById( - programmingLanguageId - ); - - if (!language) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: "Invalid programming language" - }); - } - - const runtimeInfo = findRuntime(runtimes, language.language); - - if (!runtimeInfo) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - error: `Unsupported language: ${language.language}` - }); - } - - // Execute code against all test cases - const pistonExecutionResults: PistonExecutionResponse[] = []; - const expectedOutputs: string[] = []; - - const promises = puzzle.validators.map(async (validator) => { - const pistonRequest: PistonExecutionRequest = { - language: runtimeInfo.language, - version: runtimeInfo.version, - files: [{ content: code }], - stdin: validator.input - }; - const executionResponse = await fastify.piston(pistonRequest); - return { executionResponse, output: validator.output }; - }); - - const results = await Promise.all(promises); - - results.forEach(({ executionResponse, output }) => { - pistonExecutionResults.push(executionResponse); - expectedOutputs.push(output); - }); - - try { - const result = calculateResults( - expectedOutputs, - pistonExecutionResults - ); - - const submission = new Submission({ - code: code, - puzzle: puzzleId, - user: userId, - createdAt: new Date(), - programmingLanguage: programmingLanguageId, - result: { - result: result.result, - successRate: result.successRate - } - }); - - await submission.save(); - - // Return response with specific type - cast to satisfy type checking - const response = { - submissionId: (submission._id as mongoose.Types.ObjectId).toString(), - code: submission.code ?? code, - puzzleId: submission.puzzle.toString(), - programmingLanguageId: submission.programmingLanguage.toString(), - userId: submission.user.toString(), - result: { - successRate: result.successRate, - passed: result.passed, - failed: result.failed, - total: result.total - }, - createdAt: new Date(submission.createdAt).toISOString(), - codeLength: code.length - } as SubmissionAPI.SubmitCodeResponse; - - return reply - .status(httpResponseCodes.SUCCESSFUL.CREATED) - .send(response); - } catch (error) { - fastify.log.error(error, "Error saving submission"); - - if (isValidationError(error)) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: "Validation failed" }); - } - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: "Failed to create submission" }); - } - } - ); -} diff --git a/libs/backend/src/routes/user/[username]/activity/index.ts b/libs/backend/src/routes/user/[username]/activity/index.ts deleted file mode 100644 index d24707e0..00000000 --- a/libs/backend/src/routes/user/[username]/activity/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Puzzle from "@/models/puzzle/puzzle.js"; -import Submission from "@/models/submission/submission.js"; -import User from "@/models/user/user.js"; -import { FastifyInstance } from "fastify"; -import { httpResponseCodes, isUsername, puzzleVisibilityEnum } from "types"; -import { ParamsUsername } from "../types.js"; -import { - genericReturnMessages, - userProperties -} from "@/config/generic-return-messages.js"; - -export default async function userByUsernameActivityRoutes( - fastify: FastifyInstance -) { - fastify.get("/", async (request, reply) => { - const { username } = request.params; - - if (!isUsername(username)) { - const { BAD_REQUEST } = httpResponseCodes.CLIENT_ERROR; - const { IS_INVALID } = genericReturnMessages[BAD_REQUEST]; - const { USERNAME } = userProperties; - - return reply.status(BAD_REQUEST).send({ - message: `${USERNAME} ${IS_INVALID}` - }); - } - - try { - const user = await User.findOne({ username }); - - if (!user) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ message: "User not found" }); - } - - const userId = user._id; - - const [puzzlesByUser, submissionsByUser] = await Promise.all([ - // TODO: add other puzzle visibility states as well? - Puzzle.find({ - author: userId, - visibility: puzzleVisibilityEnum.APPROVED - }), - Submission.find({ user: userId }).populate("programmingLanguage") - ]); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - user, - message: "User activity found", - activity: { puzzles: puzzlesByUser, submissions: submissionsByUser } - }); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ message: "Internal Server Error" }); - } - }); -} diff --git a/libs/backend/src/routes/user/[username]/index.ts b/libs/backend/src/routes/user/[username]/index.ts deleted file mode 100644 index 6175e32f..00000000 --- a/libs/backend/src/routes/user/[username]/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import User from "@/models/user/user.js"; -import { FastifyInstance } from "fastify"; -import { ERROR_MESSAGES, httpResponseCodes, UserAPI } from "types"; -import { ParamsUsername } from "./types.js"; - -export default async function userByUsernameRoutes(fastify: FastifyInstance) { - fastify.get("/", async (request, reply) => { - const { username } = request.params; - - const parseResult = UserAPI.getUserByUsernameRequestSchema.safeParse( - request.params - ); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - try { - const user = await User.findOne({ username }).lean(); - - if (!user) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - error: "User not found", - message: `User with username '${username}' could not be found` - }); - } - - const response = { - message: "User found", - user: { - ...user, - _id: user._id.toString() - } - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, - message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG - }); - } - }); -} diff --git a/libs/backend/src/routes/user/[username]/isAvailable/index.ts b/libs/backend/src/routes/user/[username]/isAvailable/index.ts deleted file mode 100644 index 0459e9b4..00000000 --- a/libs/backend/src/routes/user/[username]/isAvailable/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import User from "@/models/user/user.js"; -import { FastifyInstance } from "fastify"; -import { ERROR_MESSAGES, httpResponseCodes, UserAPI } from "types"; -import { ParamsUsername } from "../types.js"; - -export default async function isUsernameAvailableRoutes( - fastify: FastifyInstance -) { - fastify.get("/", async (request, reply) => { - const { username } = request.params; - - const parseResult = - UserAPI.checkUsernameAvailabilityRequestSchema.safeParse(request.params); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - try { - const user = await User.findOne({ username }); - const response: UserAPI.CheckUsernameAvailabilityResponse = { - available: !user - }; - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send(response); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - error: ERROR_MESSAGES.SERVER.INTERNAL_ERROR, - message: ERROR_MESSAGES.GENERIC.SOMETHING_WENT_WRONG - }); - } - }); -} diff --git a/libs/backend/src/routes/user/[username]/puzzle/index.ts b/libs/backend/src/routes/user/[username]/puzzle/index.ts deleted file mode 100644 index 3cbb3784..00000000 --- a/libs/backend/src/routes/user/[username]/puzzle/index.ts +++ /dev/null @@ -1,106 +0,0 @@ -import Puzzle from "@/models/puzzle/puzzle.js"; -import User from "@/models/user/user.js"; -import { FastifyInstance } from "fastify"; -import { - DEFAULT_PAGE, - httpResponseCodes, - isAuthenticatedInfo, - isUsername, - PaginatedQueryResponse, - paginatedQuerySchema, - puzzleVisibilityEnum -} from "types"; -import { ParamsUsername } from "../types.js"; -import { - genericReturnMessages, - userProperties -} from "@/config/generic-return-messages.js"; -import decodeToken from "@/plugins/middleware/decode-token.js"; - -export default async function userByUsernamePuzzleRoutes( - fastify: FastifyInstance -) { - fastify.get( - "/", - { - onRequest: decodeToken - }, - async (request, reply) => { - const { username } = request.params; - - if (!isUsername(username)) { - return reply.status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST).send({ - message: `${userProperties.USERNAME} ${ - genericReturnMessages[httpResponseCodes.CLIENT_ERROR.BAD_REQUEST] - .IS_INVALID - }` - }); - } - - const parseResult = paginatedQuerySchema.safeParse(request.query); - - if (!parseResult.success) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST) - .send({ error: parseResult.error.issues }); - } - - try { - const user = await User.findOne({ username }); - - if (!user) { - return reply - .status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND) - .send({ message: "User not found" }); - } - - const userId = user._id; - - const query = parseResult.data; - const { page, pageSize } = query; - - // Calculate pagination offsets - const offsetSkip = (page - DEFAULT_PAGE) * pageSize; - - const queryCondition: Record = { author: userId }; - - // If the user is authenticated and is the owner or contributor, fetch all puzzles - if ( - isAuthenticatedInfo(request.user) && - request.user.username === user.username - ) { - // No additional condition needed for visibility - } else { - // Otherwise, only fetch approved puzzles - queryCondition.visibility = puzzleVisibilityEnum.APPROVED; - } - - const [puzzles, total] = await Promise.all([ - Puzzle.find(queryCondition) - .populate("author") - .skip(offsetSkip) - .limit(pageSize) - .exec(), - Puzzle.countDocuments(queryCondition) - ]); - - // Calculate total pages - const totalPages = Math.ceil(total / pageSize); - - const paginatedResponse: PaginatedQueryResponse = { - page, - pageSize, - totalPages, - totalItems: total, - items: puzzles - }; - - return reply.send(paginatedResponse); - } catch (error) { - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ error: `Failed to fetch puzzles of user (${username})` }); - } - } - ); -} diff --git a/libs/backend/src/routes/user/[username]/types.d.ts b/libs/backend/src/routes/user/[username]/types.d.ts deleted file mode 100644 index 35783a35..00000000 --- a/libs/backend/src/routes/user/[username]/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type ParamsUsername = { Params: { username: string } }; diff --git a/libs/backend/src/routes/user/index.ts b/libs/backend/src/routes/user/index.ts deleted file mode 100644 index d8dba9dd..00000000 --- a/libs/backend/src/routes/user/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FastifyInstance } from "fastify"; - -export default async function userRoutes(fastify: FastifyInstance) { - // No routes defined yet -} diff --git a/libs/backend/src/routes/user/me/index.ts b/libs/backend/src/routes/user/me/index.ts deleted file mode 100644 index 304f51c3..00000000 --- a/libs/backend/src/routes/user/me/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { FastifyInstance } from "fastify"; -import authenticated from "../../../plugins/middleware/authenticated.js"; -import checkUserBan from "../../../plugins/middleware/check-user-ban.js"; -import { AuthenticatedInfo, DEFAULT_USER_ROLE, httpResponseCodes } from "types"; -import User from "../../../models/user/user.js"; -import { validateBody } from "@/plugins/middleware/validate-body.js"; -import { z } from "zod"; - -const updateProfileSchema = z.object({ - bio: z.string().max(500).optional(), - location: z.string().max(100).optional(), - picture: z.string().url().optional().or(z.literal("")), - socials: z.array(z.string().url()).max(5).optional() -}); - -export default async function userMeRoutes(fastify: FastifyInstance) { - fastify.get( - "/", - { - preHandler: [authenticated, checkUserBan] - }, - async (request, reply) => { - const user = request.user as AuthenticatedInfo | undefined; - - if (!user) { - return reply.status(401).send({ - isAuthenticated: false, - message: "Not authenticated" - }); - } - - try { - // Fetch the user from database to get the role - const dbUser = await User.findById(user.userId); - - return reply.status(200).send({ - isAuthenticated: true, - userId: user.userId, - username: user.username, - role: dbUser?.role || DEFAULT_USER_ROLE - }); - } catch (error) { - fastify.log.error(error, "Failed to fetch user data"); - return reply.status(500).send({ - isAuthenticated: false, - message: "Failed to fetch user data" - }); - } - } - ); - - fastify.patch( - "/profile", - { - preHandler: [ - authenticated, - checkUserBan, - validateBody(updateProfileSchema) - ] - }, - async (request, reply) => { - const user = request.user as AuthenticatedInfo | undefined; - - if (!user) { - return reply.status(httpResponseCodes.CLIENT_ERROR.UNAUTHORIZED).send({ - message: "Not authenticated" - }); - } - - try { - const updates = request.body as z.infer; - - const dbUser = await User.findById(user.userId); - - if (!dbUser) { - return reply.status(httpResponseCodes.CLIENT_ERROR.NOT_FOUND).send({ - message: "User not found" - }); - } - - // Update profile fields - if (!dbUser.profile) { - dbUser.profile = {}; - } - - if (updates.bio !== undefined) dbUser.profile.bio = updates.bio; - if (updates.location !== undefined) - dbUser.profile.location = updates.location; - if (updates.picture !== undefined) - dbUser.profile.picture = updates.picture; - if (updates.socials !== undefined) - dbUser.profile.socials = updates.socials; - - await dbUser.save(); - - return reply.status(httpResponseCodes.SUCCESSFUL.OK).send({ - message: "Profile updated successfully", - profile: dbUser.profile - }); - } catch (error) { - fastify.log.error(error, "Failed to update profile"); - return reply - .status(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR) - .send({ - message: "Failed to update profile" - }); - } - } - ); -} diff --git a/libs/backend/src/seeds/clear.ts b/libs/backend/src/seeds/clear.ts deleted file mode 100644 index 56466e90..00000000 --- a/libs/backend/src/seeds/clear.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node - -import { config } from "dotenv"; -import { - connectToDatabase, - disconnectFromDatabase -} from "./utils/db-connection.js"; -import { clearDatabase, getCollectionCounts } from "./utils/clear-database.js"; - -config(); - -/** - * Script to clear the database - */ -async function clear() { - console.log("🗑️ Database Clear Utility\n"); - console.log("=".repeat(50)); - - try { - await connectToDatabase(); - - // Show current counts - console.log("\n📊 Current Database Counts:\n"); - const beforeCounts = await getCollectionCounts(); - Object.entries(beforeCounts).forEach(([collection, count]) => { - console.log(` ${collection.padEnd(15)}: ${count}`); - }); - - // Clear database (requires confirmation unless --force) - await clearDatabase(process.argv.includes("--force")); - - // Show final counts - console.log("\n📊 Final Database Counts:\n"); - const afterCounts = await getCollectionCounts(); - Object.entries(afterCounts).forEach(([collection, count]) => { - console.log(` ${collection.padEnd(15)}: ${count}`); - }); - - console.log("\n" + "=".repeat(50)); - console.log("✅ Database clear completed\n"); - } catch (error) { - console.error("\n❌ Clear operation failed:", error); - process.exit(1); - } finally { - await disconnectFromDatabase(); - } -} - -clear(); diff --git a/libs/backend/src/seeds/config/seed-presets.ts b/libs/backend/src/seeds/config/seed-presets.ts deleted file mode 100644 index 2ead75cf..00000000 --- a/libs/backend/src/seeds/config/seed-presets.ts +++ /dev/null @@ -1,88 +0,0 @@ -export interface SeedPreset { - name: string; - counts: { - users: number; - puzzles: number; - submissionsPerPuzzle: number; - commentsPerPuzzle: number; - reports: number; - games: number; - }; -} - -export const SEED_PRESETS: Record = { - minimal: { - name: "Minimal", - counts: { - users: 5, - puzzles: 5, - submissionsPerPuzzle: 2, - commentsPerPuzzle: 2, - reports: 2, - games: 2 - } - }, - - standard: { - name: "Standard", - counts: { - users: 20, - puzzles: 30, - submissionsPerPuzzle: 5, - commentsPerPuzzle: 8, - reports: 12, - games: 15 - } - }, - - comprehensive: { - name: "Comprehensive", - counts: { - users: 100, - puzzles: 150, - submissionsPerPuzzle: 15, - commentsPerPuzzle: 20, - reports: 50, - games: 75 - } - }, - - demo: { - name: "Demo", - counts: { - users: 25, - puzzles: 40, - submissionsPerPuzzle: 8, - commentsPerPuzzle: 12, - reports: 15, - games: 20 - } - } -}; - -export function getSeedPreset(presetName?: string): SeedPreset { - const name = presetName?.toLowerCase() || "standard"; - return SEED_PRESETS[name] || SEED_PRESETS.standard; -} - -export function getSeedCounts( - getEnvNumber: (key: string, defaultValue: number) => number -) { - const presetName = process.env.SEED_PRESET; - const preset = getSeedPreset(presetName); - - return { - users: getEnvNumber("SEED_USERS", preset.counts.users), - puzzles: getEnvNumber("SEED_PUZZLES", preset.counts.puzzles), - submissionsPerPuzzle: getEnvNumber( - "SEED_SUBMISSIONS_PER_PUZZLE", - preset.counts.submissionsPerPuzzle - ), - commentsPerPuzzle: getEnvNumber( - "SEED_COMMENTS_PER_PUZZLE", - preset.counts.commentsPerPuzzle - ), - reports: getEnvNumber("SEED_REPORTS", preset.counts.reports), - games: getEnvNumber("SEED_GAMES", preset.counts.games) - }; -} diff --git a/libs/backend/src/seeds/factories/chat-message.factory.ts b/libs/backend/src/seeds/factories/chat-message.factory.ts deleted file mode 100644 index 0209fa33..00000000 --- a/libs/backend/src/seeds/factories/chat-message.factory.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { faker } from "@faker-js/faker"; -import ChatMessage from "../../models/chat/chat-message.js"; -import User from "../../models/user/user.js"; -import { ChatMessageEntity } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; - -export interface ChatMessageFactoryOptions { - gameId: Types.ObjectId; - userId: Types.ObjectId; - username?: string; -} - -/** - * Generate realistic game chat messages - */ -function generateChatMessage(): string { - const messageTemplates = [ - () => "Good luck everyone!", - () => "GL HF!", - () => `Nice approach!`, - () => - `${faker.helpers.arrayElement(["Interesting", "Cool", "Smart"])} solution`, - () => - `Anyone using ${randomFromArray(["Python", "JavaScript", "Java", "C++"])}?`, - () => "GG", - () => "Well played!", - () => - `This puzzle is ${faker.helpers.arrayElement(["tough", "tricky", "interesting", "fun"])}!`, - () => `${faker.number.int({ min: 1, max: 100 })}% done`, - () => "Almost there!", - () => "First time playing this mode", - () => `Time's running out!`, - () => `Let's do this!`, - () => faker.helpers.arrayElement(["😊", "👍", "🎉", "🔥", "💪"]), - () => `${faker.helpers.arrayElement(["Found", "Got", "Solved"])} it!`, - () => "Quick question about the constraints", - () => `Testing edge case ${faker.number.int({ min: 1, max: 10 })}`, - () => "Thanks for the game!", - () => "Rematch?", - () => `Love this ${randomFromArray(["puzzle", "challenge", "problem"])}` - ]; - - return randomFromArray(messageTemplates)(); -} - -/** - * Create a single chat message - */ -export async function createChatMessage( - options: ChatMessageFactoryOptions -): Promise { - let username = options.username; - - // If username not provided, fetch it from the user - if (!username) { - const user = await User.findById(options.userId).select("username"); - if (!user) throw new Error("User not found for chat message"); - username = user.username; - } - - const chatMessageData: Partial = { - gameId: options.gameId.toString(), - userId: options.userId.toString(), - username, - message: generateChatMessage(), - isDeleted: faker.datatype.boolean({ probability: 0.05 }), // 5% deleted messages - createdAt: faker.date.recent({ days: 30 }), - updatedAt: faker.date.recent({ days: 30 }) - }; - - const chatMessage = new ChatMessage(chatMessageData); - await chatMessage.save(); - - return chatMessage._id as Types.ObjectId; -} - -/** - * Create chat messages for a game - */ -export async function createChatMessagesForGame( - gameId: Types.ObjectId, - playerIds: Types.ObjectId[], - messageCount: number = 10 -): Promise { - const chatMessageIds: Types.ObjectId[] = []; - - // Fetch usernames once for all players - const users = await User.find({ _id: { $in: playerIds } }) - .select("_id username") - .lean(); - const userMap = new Map( - users.map((u) => [(u._id as Types.ObjectId).toString(), u.username]) - ); - - for (let i = 0; i < messageCount; i++) { - const userId = randomFromArray(playerIds); - const username = userMap.get(userId.toString()); - - if (!username) continue; - - chatMessageIds.push( - await createChatMessage({ - gameId, - userId, - username - }) - ); - } - - return chatMessageIds; -} - -/** - * Create chat messages for multiple games - */ -export async function createChatMessages( - gameIds: Types.ObjectId[], - gamePlayerMap: Map -): Promise { - const chatMessageIds: Types.ObjectId[] = []; - - for (const gameId of gameIds) { - const playerIds = gamePlayerMap.get(gameId.toString()); - if (!playerIds || playerIds.length === 0) continue; - - // Each game gets 5-20 messages - const messageCount = faker.number.int({ min: 5, max: 20 }); - const messages = await createChatMessagesForGame( - gameId, - playerIds, - messageCount - ); - chatMessageIds.push(...messages); - } - - return chatMessageIds; -} diff --git a/libs/backend/src/seeds/factories/comment.factory.ts b/libs/backend/src/seeds/factories/comment.factory.ts deleted file mode 100644 index d43314cd..00000000 --- a/libs/backend/src/seeds/factories/comment.factory.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Comment from "../../models/comment/comment.js"; -import Puzzle from "../../models/puzzle/puzzle.js"; -import { commentTypeEnum, CommentEntity } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; - -type CommentTypeValue = (typeof commentTypeEnum)[keyof typeof commentTypeEnum]; - -export interface CommentFactoryOptions { - authorId: Types.ObjectId; - parentId: Types.ObjectId; - commentType: CommentTypeValue; -} - -/** - * Generate realistic comment text - */ -function generateCommentText(): string { - const commentTemplates = [ - () => `Great puzzle! ${faker.lorem.sentence()}`, - () => `This was challenging. ${faker.lorem.sentences(2)}`, - () => - `I think there's a bug in test case ${faker.number.int({ min: 1, max: 10 })}.`, - () => `Here's my approach: ${faker.lorem.paragraph()}`, - () => `Can someone explain ${faker.lorem.words(3)}?`, - () => `Nice solution! ${faker.lorem.sentence()}`, - () => faker.lorem.sentences(faker.number.int({ min: 1, max: 3 })), - () => - `I solved this using ${randomFromArray(["recursion", "dynamic programming", "greedy algorithm", "BFS", "DFS"])}`, - () => - `The time complexity of my solution is O(${randomFromArray(["n", "n log n", "n²", "1"])})` - ]; - - return randomFromArray(commentTemplates)(); -} - -/** - * Create a single comment - */ -export async function createComment( - options: CommentFactoryOptions -): Promise { - const commentData: Partial = { - author: options.authorId.toString(), - text: generateCommentText(), - upvote: faker.number.int({ min: 0, max: 50 }), - downvote: faker.number.int({ min: 0, max: 10 }), - commentType: options.commentType, - parentId: options.parentId.toString(), - comments: [] // Nested comments added separately - }; - - const comment = new Comment(commentData); - await comment.save(); - - if (options.commentType === commentTypeEnum.PUZZLE) { - await Puzzle.findByIdAndUpdate(options.parentId, { - $push: { comments: comment._id } - }); - } else if (options.commentType === commentTypeEnum.COMMENT) { - await Comment.findByIdAndUpdate(options.parentId, { - $push: { comments: comment._id } - }); - } - - return comment._id as unknown as Types.ObjectId; -} - -/** - * Create nested comment replies - */ -export async function createNestedComments( - parentCommentId: Types.ObjectId, - authorIds: Types.ObjectId[], - maxDepth = 4, - currentDepth = 0 -): Promise { - if (currentDepth >= maxDepth) return; - - if (!faker.datatype.boolean(0.5)) return; - - const replyCount = faker.number.int({ min: 1, max: 3 }); - - for (let i = 0; i < replyCount; i++) { - const authorId = randomFromArray(authorIds); - const replyId = await createComment({ - authorId, - parentId: parentCommentId, - commentType: commentTypeEnum.COMMENT - }); - - // Recursively create nested replies - await createNestedComments(replyId, authorIds, maxDepth, currentDepth + 1); - } -} - -/** - * Create multiple puzzle comments with nested replies - */ -export async function createPuzzleComments( - count: number, - userIds: Types.ObjectId[], - puzzleIds: Types.ObjectId[] -): Promise { - const commentIds: Types.ObjectId[] = []; - - for (let i = 0; i < count; i++) { - const authorId = randomFromArray(userIds); - const puzzleId = randomFromArray(puzzleIds); - - const commentId = await createComment({ - authorId, - parentId: puzzleId, - commentType: commentTypeEnum.PUZZLE - }); - - commentIds.push(commentId); - - if (faker.datatype.boolean(0.5)) { - await createNestedComments(commentId, userIds, 2); - } - } - - return commentIds; -} diff --git a/libs/backend/src/seeds/factories/game.factory.ts b/libs/backend/src/seeds/factories/game.factory.ts deleted file mode 100644 index 3fc791dc..00000000 --- a/libs/backend/src/seeds/factories/game.factory.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Game, { GameDocument } from "../../models/game/game.js"; -import { - gameModeEnum, - gameVisibilityEnum, - GameMode, - GameVisibility -} from "types"; -import { - randomFromArray, - randomMultipleFromArray -} from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; - -export interface GameFactoryOptions { - ownerId: Types.ObjectId; - puzzleId: Types.ObjectId; - playerIds: Types.ObjectId[]; - mode?: GameMode; - visibility?: GameVisibility; - rated?: boolean; -} - -/** - * Generate game allowed languages from database - */ -async function generateAllowedLanguages(): Promise { - // Get all available programming languages from database - const allLanguages = await ProgrammingLanguage.find().lean(); - - if (allLanguages.length === 0) { - throw new Error( - "No programming languages found in database. Run migrations first!" - ); - } - - // Select 1-4 languages randomly - const count = faker.number.int({ - min: 1, - max: Math.min(4, allLanguages.length) - }); - const selectedLanguages = randomMultipleFromArray(allLanguages, count, count); - - return selectedLanguages.map((lang) => lang._id.toString()); -} - -/** - * Create a single game with realistic data - */ -export async function createGame( - options: GameFactoryOptions -): Promise { - const mode = options.mode || randomFromArray(Object.values(gameModeEnum)); - const visibility = - options.visibility || randomFromArray(Object.values(gameVisibilityEnum)); - - // Game duration varies: 5min to 60min - const durationInSeconds = faker.number.int({ min: 300, max: 3600 }); - - // Start time can be in the past (completed) or future (scheduled) - const isPast = faker.datatype.boolean({ probability: 0.7 }); // 70% past games - const startTime = isPast - ? faker.date.recent({ days: 30 }) - : faker.date.soon({ days: 7 }); - - const endTime = new Date(startTime.getTime() + durationInSeconds * 1000); - - // Select 2-4 players from provided player IDs - const playerCount = Math.min( - faker.number.int({ min: 2, max: 4 }), - options.playerIds.length - ); - const players = randomMultipleFromArray( - options.playerIds, - playerCount, - playerCount - ); - - // Ensure owner is in the players list - if (!players.includes(options.ownerId)) { - players[0] = options.ownerId; - } - - const gameData: Partial = { - owner: options.ownerId.toString(), - puzzle: options.puzzleId.toString(), - players: players.map((id) => id.toString()), - startTime, - endTime, - options: { - mode, - visibility, - maxGameDurationInSeconds: durationInSeconds, - allowedLanguages: await generateAllowedLanguages(), - rated: faker.datatype.boolean({ probability: 0.6 }) - }, - playerSubmissions: [] - }; - - const game = new Game(gameData); - await game.save(); - - return game._id as Types.ObjectId; -} - -/** - * Create multiple games with variety - */ -export async function createGames( - count: number, - userIds: Types.ObjectId[], - puzzleIds: Types.ObjectId[] -): Promise { - const gameIds: Types.ObjectId[] = []; - - for (let i = 0; i < count; i++) { - const ownerId = randomFromArray(userIds); - const puzzleId = randomFromArray(puzzleIds); - - const mode = randomFromArray(Object.values(gameModeEnum)); - - // Visibility distribution: 70% PUBLIC, 30% PRIVATE - const visibility = faker.datatype.boolean({ probability: 0.7 }) - ? gameVisibilityEnum.PUBLIC - : gameVisibilityEnum.PRIVATE; - - // Get random players (ensure we have enough users) - const playerCount = Math.min( - faker.number.int({ min: 2, max: 4 }), - userIds.length - ); - const playerIds = randomMultipleFromArray( - userIds, - playerCount, - playerCount - ); - - gameIds.push( - await createGame({ - ownerId, - puzzleId, - playerIds, - mode, - visibility, - rated: faker.datatype.boolean() - }) - ); - } - - return gameIds; -} diff --git a/libs/backend/src/seeds/factories/preferences.factory.ts b/libs/backend/src/seeds/factories/preferences.factory.ts deleted file mode 100644 index 217c1974..00000000 --- a/libs/backend/src/seeds/factories/preferences.factory.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Preferences, { - PreferencesDocument -} from "../../models/preferences/preferences.js"; -import { themeOption } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; -import { ObjectId } from "mongoose"; - -export interface PreferencesFactoryOptions { - ownerId: Types.ObjectId; -} - -/** - * Create user preferences - */ -export async function createPreferences( - options: PreferencesFactoryOptions -): Promise { - const themes = Object.values(themeOption); - - const preferencesData: Partial = { - owner: options.ownerId as unknown as ObjectId, - ...(faker.helpers.maybe(() => ({ theme: randomFromArray(themes) }), { - probability: 0.7 - }) || {}), - ...(faker.helpers.maybe( - () => ({ - preferredLanguage: randomFromArray([ - "python", - "javascript", - "java", - "cpp" - ]) - }), - { probability: 0.6 } - ) || {}), - blockedUsers: [] // Could add some blocked users if needed - // Editor preferences will use schema defaults - }; - - const preferences = new Preferences(preferencesData); - await preferences.save(); - - return preferences._id as Types.ObjectId; -} - -/** - * Create preferences for multiple users - */ -export async function createMultiplePreferences( - userIds: Types.ObjectId[] -): Promise { - const preferencesIds: Types.ObjectId[] = []; - - const userCount = Math.ceil(userIds.length * 0.2); - - for (let i = 0; i < userCount; i++) { - preferencesIds.push( - await createPreferences({ - ownerId: userIds[i] - }) - ); - } - - return preferencesIds; -} diff --git a/libs/backend/src/seeds/factories/puzzle.factory.ts b/libs/backend/src/seeds/factories/puzzle.factory.ts deleted file mode 100644 index d13d0308..00000000 --- a/libs/backend/src/seeds/factories/puzzle.factory.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Puzzle from "../../models/puzzle/puzzle.js"; -import { - DifficultyEnum, - puzzleVisibilityEnum, - TagEnum, - PuzzleEntity -} from "types"; -import { - randomFromArray, - randomMultipleFromArray -} from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; - -type DifficultyValue = (typeof DifficultyEnum)[keyof typeof DifficultyEnum]; -type VisibilityValue = - (typeof puzzleVisibilityEnum)[keyof typeof puzzleVisibilityEnum]; - -export interface PuzzleFactoryOptions { - authorId: Types.ObjectId; - visibility?: VisibilityValue; - difficulty?: DifficultyValue; -} - -/** - * Generate realistic test cases for a puzzle - */ -function generateValidators(count: number) { - const validators = []; - - for (let i = 0; i < count; i++) { - validators.push({ - input: faker.helpers.arrayElement([ - faker.number.int({ min: 1, max: 100 }).toString(), - `${faker.number.int({ min: 1, max: 10 })} ${faker.number.int({ min: 1, max: 10 })}`, - faker.lorem.word(), - JSON.stringify([faker.number.int(), faker.number.int()]) - ]), - output: faker.helpers.arrayElement([ - faker.number.int({ min: 1, max: 100 }).toString(), - faker.datatype.boolean().toString(), - faker.lorem.word() - ]), - createdAt: new Date(), - updatedAt: new Date() - }); - } - - return validators; -} - -/** - * Generate puzzle title based on difficulty and tags - */ -function generatePuzzleTitle( - difficulty: DifficultyValue, - tags: string[] -): string { - const titleTemplates = [ - `${faker.hacker.verb()} the ${faker.hacker.noun()}`, - `${faker.lorem.words(2)} Challenge`, - `Find the ${faker.hacker.adjective()} ${faker.hacker.noun()}`, - `${faker.lorem.word()} Algorithm`, - `Reverse ${faker.lorem.word()}`, - `Calculate ${faker.lorem.words(2)}`, - `Optimize ${faker.hacker.noun()} Processing` - ]; - - return faker.helpers.arrayElement(titleTemplates); -} - -/** - * Create a single puzzle with realistic data - */ -export async function createPuzzle( - options: PuzzleFactoryOptions -): Promise { - const difficulty = - options.difficulty || randomFromArray(Object.values(DifficultyEnum)); - - const visibility = - options.visibility || randomFromArray(Object.values(puzzleVisibilityEnum)); - - // Select 1-4 tags - const tags = randomMultipleFromArray(Object.values(TagEnum), 1, 4); - const title = generatePuzzleTitle(difficulty, tags); - - // Number of test cases varies by difficulty - const testCaseCount: Record = { - [DifficultyEnum.BEGINNER]: faker.number.int({ min: 3, max: 5 }), - [DifficultyEnum.INTERMEDIATE]: faker.number.int({ min: 5, max: 8 }), - [DifficultyEnum.ADVANCED]: faker.number.int({ min: 8, max: 12 }), - [DifficultyEnum.EXPERT]: faker.number.int({ min: 10, max: 15 }) - }; - - const puzzleData: Partial = { - title, - statement: faker.lorem.paragraphs(faker.number.int({ min: 2, max: 4 })), - constraints: faker.helpers.maybe( - () => - `- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}`, - { probability: 0.7 } - ), - author: options.authorId.toString(), - validators: generateValidators(testCaseCount[difficulty]), - difficulty, - visibility, - tags, - ...(await (async () => { - if ( - faker.helpers.maybe(() => true, { - probability: visibility === puzzleVisibilityEnum.APPROVED ? 0.9 : 0.3 - }) - ) { - // Get a random programming language from database for the solution - const allLanguages = await ProgrammingLanguage.find().lean(); - if (allLanguages.length > 0) { - const selectedLanguage = randomFromArray(allLanguages); - return { - solution: { - code: faker.helpers.arrayElement([ - "def solution(n):\n return n * 2", - "function solution(arr) {\n return arr.sort();\n}", - "public int solution(int x) {\n return x + 1;\n}" - ]), - programmingLanguage: selectedLanguage._id.toString(), - explanation: faker.lorem.paragraph() - } - }; - } - } - return {}; - })()), - ...(faker.helpers.maybe( - () => ({ moderationFeedback: faker.lorem.sentence() }), - { probability: visibility === puzzleVisibilityEnum.DRAFT ? 0.4 : 0.1 } - ) || {}) - }; - - const puzzle = new Puzzle(puzzleData); - await puzzle.save(); - - return puzzle._id as Types.ObjectId; -} - -/** - * Create multiple puzzles with variety - */ -export async function createPuzzles( - count: number, - authorIds: Types.ObjectId[] -): Promise { - const puzzleIds: Types.ObjectId[] = []; - - for (let i = 0; i < count; i++) { - const authorId = randomFromArray(authorIds); - - // Distribution: 60% APPROVED, 15% DRAFT, 10% ARCHIVED, 10% REVIEW, 5% others - const visibilityValues = Object.values(puzzleVisibilityEnum); - const visibility = faker.helpers.weightedArrayElement([ - { value: visibilityValues[4], weight: 60 }, // APPROVED - { value: visibilityValues[0], weight: 15 }, // DRAFT - { value: visibilityValues[6], weight: 10 }, // ARCHIVED - { value: visibilityValues[2], weight: 10 }, // REVIEW - { value: visibilityValues[1], weight: 3 }, // READY - { value: visibilityValues[3], weight: 1 }, // REVISE - { value: visibilityValues[5], weight: 1 } // INACTIVE - ]) as VisibilityValue; - - // Difficulty distribution - const difficultyValues = Object.values(DifficultyEnum); - const difficulty = faker.helpers.weightedArrayElement([ - { value: difficultyValues[0], weight: 30 }, // BEGINNER - { value: difficultyValues[1], weight: 40 }, // INTERMEDIATE - { value: difficultyValues[2], weight: 20 }, // ADVANCED - { value: difficultyValues[3], weight: 10 } // EXPERT - ]) as DifficultyValue; - - puzzleIds.push( - await createPuzzle({ - authorId, - visibility, - difficulty - }) - ); - } - - return puzzleIds; -} diff --git a/libs/backend/src/seeds/factories/report.factory.ts b/libs/backend/src/seeds/factories/report.factory.ts deleted file mode 100644 index 97c7917d..00000000 --- a/libs/backend/src/seeds/factories/report.factory.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Report from "../../models/report/report.js"; -import User from "../../models/user/user.js"; -import { ProblemTypeEnum, ReportEntity, reviewStatusEnum } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; - -type ProblemTypeValue = (typeof ProblemTypeEnum)[keyof typeof ProblemTypeEnum]; -type ReviewStatusValue = - (typeof reviewStatusEnum)[keyof typeof reviewStatusEnum]; - -export interface ReportFactoryOptions { - reportedById: Types.ObjectId; - problematicIdentifier: Types.ObjectId; - problemType: ProblemTypeValue; - resolvedById?: Types.ObjectId; -} - -/** - * Generate realistic report explanation - */ -function generateReportExplanation(problemType: ProblemTypeValue): string { - const explanations: Record = { - [ProblemTypeEnum.PUZZLE]: [ - "This puzzle contains inappropriate content", - "The test cases are incorrect", - "Puzzle statement is unclear and confusing", - "Contains offensive language" - ], - [ProblemTypeEnum.USER]: [ - "User is harassing other members", - "Spamming comments across multiple puzzles", - "Using inappropriate username", - "Cheating in multiplayer games" - ], - [ProblemTypeEnum.COMMENT]: [ - "Comment contains spam", - "Offensive or inappropriate language", - "Personal attack on another user", - "Off-topic or irrelevant content" - ], - [ProblemTypeEnum.GAME_CHAT]: [ - "Inappropriate messages in game chat", - "Harassment of other players", - "Spam in chat", - "Offensive language" - ] - }; - - return randomFromArray( - explanations[problemType] || explanations[ProblemTypeEnum.COMMENT] - ); -} - -/** - * Create a report - */ -export async function createReport( - options: ReportFactoryOptions -): Promise { - // Status distribution: 60% PENDING, 30% RESOLVED, 10% REJECTED - const statusValues = Object.values(reviewStatusEnum); - const status = faker.helpers.weightedArrayElement([ - { value: statusValues[0], weight: 60 }, // PENDING - { value: statusValues[1], weight: 30 }, // RESOLVED - { value: statusValues[2], weight: 10 } // REJECTED - ]) as ReviewStatusValue; - - const reportData: Partial = { - problematicIdentifier: options.problematicIdentifier.toString(), - problemType: options.problemType, - reportedBy: options.reportedById.toString(), - explanation: generateReportExplanation(options.problemType), - status, - ...(status !== reviewStatusEnum.PENDING && options.resolvedById - ? { resolvedBy: options.resolvedById.toString() } - : {}), - createdAt: faker.date.recent({ days: 30 }), - ...(status !== reviewStatusEnum.PENDING - ? { updatedAt: faker.date.recent({ days: 15 }) } - : { updatedAt: faker.date.recent({ days: 30 }) }) - }; - - const report = new Report(reportData); - await report.save(); - - // Update user reportCount if resolved - if ( - status === reviewStatusEnum.RESOLVED && - options.problemType === ProblemTypeEnum.USER - ) { - await User.findByIdAndUpdate(options.problematicIdentifier, { - $inc: { reportCount: 1 } - }); - } - - return report._id as Types.ObjectId; -} - -/** - * Create multiple reports - */ -export async function createReports( - count: number, - userIds: Types.ObjectId[], - puzzleIds: Types.ObjectId[], - moderatorIds: Types.ObjectId[] -): Promise { - const reportIds: Types.ObjectId[] = []; - - for (let i = 0; i < count; i++) { - const reportedById = randomFromArray(userIds); - const resolvedById = randomFromArray(moderatorIds); - - // Problem type distribution - const problemTypeValues = Object.values(ProblemTypeEnum); - const problemType = randomFromArray([ - problemTypeValues[0], // PUZZLE - problemTypeValues[1], // USER - problemTypeValues[2] // COMMENT - ]) as ProblemTypeValue; - - let problematicIdentifier: Types.ObjectId; - if (problemType === problemTypeValues[0]) { - // PUZZLE - problematicIdentifier = randomFromArray(puzzleIds); - } else { - problematicIdentifier = randomFromArray(userIds); - } - - reportIds.push( - await createReport({ - reportedById, - problematicIdentifier, - problemType, - resolvedById - }) - ); - } - - return reportIds; -} diff --git a/libs/backend/src/seeds/factories/submission.factory.ts b/libs/backend/src/seeds/factories/submission.factory.ts deleted file mode 100644 index b643750d..00000000 --- a/libs/backend/src/seeds/factories/submission.factory.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { faker } from "@faker-js/faker"; -import Submission from "../../models/submission/submission.js"; -import { PuzzleResultEnum } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types, ObjectId } from "mongoose"; -import ProgrammingLanguage from "../../models/programming-language/language.js"; - -type PuzzleResultValue = - (typeof PuzzleResultEnum)[keyof typeof PuzzleResultEnum]; - -export interface SubmissionFactoryOptions { - userId: Types.ObjectId; - puzzleId: Types.ObjectId; - result?: PuzzleResultValue; -} - -/** - * Generate code submission based on language - */ -function generateCode(language: string): string { - const codeTemplates: Record = { - python: [ - "def solution(n):\n # TODO: implement solution\n return n", - "def solve(arr):\n result = []\n for item in arr:\n result.append(item * 2)\n return result", - "def calculate(x, y):\n return x + y" - ], - javascript: [ - "function solution(n) {\n // TODO: implement solution\n return n;\n}", - "const solve = (arr) => {\n return arr.map(x => x * 2);\n}", - "function calculate(x, y) {\n return x + y;\n}" - ], - java: [ - "public int solution(int n) {\n // TODO: implement solution\n return n;\n}", - "public int[] solve(int[] arr) {\n return Arrays.stream(arr).map(x -> x * 2).toArray();\n}" - ], - cpp: [ - "int solution(int n) {\n // TODO: implement solution\n return n;\n}", - "vector solve(vector arr) {\n for (auto& x : arr) x *= 2;\n return arr;\n}" - ] - }; - - return randomFromArray(codeTemplates[language] || codeTemplates.python); -} - -/** - * Generate result info based on puzzle validators - */ -async function generateResultInfo( - puzzleId: Types.ObjectId, - result: PuzzleResultValue -) { - let successRate: number; - if (result === PuzzleResultEnum.SUCCESS) { - successRate = 1.0; - } else if (result === PuzzleResultEnum.ERROR) { - successRate = faker.number.float({ min: 0, max: 0.5 }); - } else { - successRate = faker.number.float({ min: 0, max: 1 }); - } - - return { - result, - successRate - }; -} - -/** - * Create a single submission - */ -export async function createSubmission( - options: SubmissionFactoryOptions -): Promise { - // Get a random programming language from database - const allLanguages = await ProgrammingLanguage.find().lean(); - if (allLanguages.length === 0) { - throw new Error( - "No programming languages found in database. Run migrations first!" - ); - } - - const selectedLanguage = randomFromArray(allLanguages); - const languageName = selectedLanguage.language; - - // Result distribution: 60% SUCCESS, 30% ERROR, 10% UNKNOWN - const resultValues = Object.values(PuzzleResultEnum); - const result = - options.result || - (faker.helpers.weightedArrayElement([ - { value: resultValues[1], weight: 60 }, // SUCCESS - { value: resultValues[0], weight: 30 }, // ERROR - { value: resultValues[2], weight: 10 } // UNKNOWN - ]) as PuzzleResultValue); - - const code = generateCode(languageName); - const submission = new Submission({ - code, - codeLength: code.length, - puzzle: options.puzzleId as unknown as ObjectId, - user: options.userId as unknown as ObjectId, - programmingLanguage: selectedLanguage._id as unknown as ObjectId, - result: await generateResultInfo(options.puzzleId, result) - }); - await submission.save(); - - return submission._id as Types.ObjectId; -} - -/** - * Create multiple submissions for puzzles - */ -export async function createSubmissions( - count: number, - userIds: Types.ObjectId[], - puzzleIds: Types.ObjectId[] -): Promise { - const submissionIds: Types.ObjectId[] = []; - - for (let i = 0; i < count; i++) { - const userId = randomFromArray(userIds); - const puzzleId = randomFromArray(puzzleIds); - - submissionIds.push( - await createSubmission({ - userId, - puzzleId - }) - ); - } - - return submissionIds; -} diff --git a/libs/backend/src/seeds/factories/user-ban.factory.ts b/libs/backend/src/seeds/factories/user-ban.factory.ts deleted file mode 100644 index 6e1ad420..00000000 --- a/libs/backend/src/seeds/factories/user-ban.factory.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { faker } from "@faker-js/faker"; -import UserBan from "../../models/moderation/user-ban.js"; -import User from "../../models/user/user.js"; -import { banTypeEnum, UserBanEntity } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; - -type BanTypeValue = (typeof banTypeEnum)[keyof typeof banTypeEnum]; - -export interface UserBanFactoryOptions { - userId: Types.ObjectId; - bannedById: Types.ObjectId; - banType?: BanTypeValue; - isActive?: boolean; -} - -/** - * Create a user ban record - */ -export async function createUserBan( - options: UserBanFactoryOptions -): Promise { - const banType = - options.banType || randomFromArray(Object.values(banTypeEnum)); - - const isActive = - options.isActive ?? faker.datatype.boolean({ probability: 0.7 }); - - const startDate = faker.date.recent({ days: 60 }); - const endDate = - banType === banTypeEnum.TEMPORARY - ? faker.date.future({ years: 0.5, refDate: startDate }) - : undefined; - - const banReasons = [ - "Spamming comments", - "Inappropriate content", - "Harassment of other users", - "Cheating in games", - "Multiple rule violations", - "Posting offensive material", - "Abuse of reporting system" - ]; - - const banData: Partial = { - userId: options.userId.toString(), - bannedBy: options.bannedById.toString(), - banType, - reason: randomFromArray(banReasons), - startDate, - endDate, - isActive, - createdAt: startDate, - updatedAt: isActive ? startDate : faker.date.recent({ days: 30 }) - }; - - const userBan = new UserBan(banData); - await userBan.save(); - - // Update user's currentBan if active - if (isActive) { - await User.findByIdAndUpdate(options.userId, { - currentBan: userBan._id, - $inc: { banCount: 1 } - }); - } - - return userBan._id as Types.ObjectId; -} - -/** - * Create user bans for some users - */ -export async function createUserBans( - userIds: Types.ObjectId[], - moderatorIds: Types.ObjectId[] -): Promise { - const banIds: Types.ObjectId[] = []; - - // Ban 10-15% of users - const banCount = Math.ceil(userIds.length * 0.12); - - for (let i = 0; i < banCount; i++) { - const userId = randomFromArray(userIds); - const bannedById = randomFromArray(moderatorIds); - - // 70% temporary, 30% permanent - const banTypeValues = Object.values(banTypeEnum); - const banType = faker.datatype.boolean({ probability: 0.7 }) - ? banTypeValues[0] // TEMPORARY - : banTypeValues[1]; // PERMANENT - - banIds.push( - await createUserBan({ - userId, - bannedById, - banType - }) - ); - } - - return banIds; -} diff --git a/libs/backend/src/seeds/factories/user-vote.factory.ts b/libs/backend/src/seeds/factories/user-vote.factory.ts deleted file mode 100644 index 652bada4..00000000 --- a/libs/backend/src/seeds/factories/user-vote.factory.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { faker } from "@faker-js/faker"; -import UserVote from "../../models/user/user-vote.js"; -import Comment from "../../models/comment/comment.js"; -import { UserVoteEntity, voteTypeEnum } from "types"; -import { randomFromArray } from "../utils/seed-helpers.js"; -import { Types } from "mongoose"; - -type VoteTypeValue = (typeof voteTypeEnum)[keyof typeof voteTypeEnum]; - -export interface UserVoteFactoryOptions { - authorId: Types.ObjectId; - votedOnId: Types.ObjectId; - voteType?: VoteTypeValue; -} - -/** - * Create a single user vote - */ -export async function createUserVote( - options: UserVoteFactoryOptions -): Promise { - const voteType = - options.voteType || randomFromArray(Object.values(voteTypeEnum)); - - const voteData: Partial = { - author: options.authorId.toString(), - votedOn: options.votedOnId.toString(), - type: voteType, - createdAt: faker.date.recent({ days: 30 }) - }; - - const vote = new UserVote(voteData); - await vote.save(); - - // Update the voted-on entity's vote count - // This could be a Comment or other votable entity - const incrementField = - voteType === voteTypeEnum.UPVOTE ? "upvote" : "downvote"; - - // Try to update as comment first - await Comment.findByIdAndUpdate(options.votedOnId, { - $inc: { [incrementField]: 1 } - }); - - // Could also update other votable entities like puzzles if they have vote fields - // For now, just handling comments - - return vote._id as Types.ObjectId; -} - -/** - * Create votes for comments - */ -export async function createVotesForComments( - commentIds: Types.ObjectId[], - userIds: Types.ObjectId[] -): Promise { - const voteIds: Types.ObjectId[] = []; - - // Each comment gets 0-10 votes - for (const commentId of commentIds) { - const voteCount = faker.number.int({ min: 0, max: 10 }); - - // Select random voters (no duplicates per comment) - const voters = randomFromArray(userIds); - const uniqueVoters = new Set(); - - for (let i = 0; i < voteCount && uniqueVoters.size < userIds.length; i++) { - const voterId = voters; - - if (!uniqueVoters.has(voterId)) { - uniqueVoters.add(voterId); - - // 70% upvotes, 30% downvotes - const voteType = faker.datatype.boolean({ probability: 0.7 }) - ? voteTypeEnum.UPVOTE - : voteTypeEnum.DOWNVOTE; - - voteIds.push( - await createUserVote({ - authorId: voterId, - votedOnId: commentId, - voteType - }) - ); - } - } - } - - return voteIds; -} - -/** - * Create votes for various entities - */ -export async function createUserVotes( - commentIds: Types.ObjectId[], - userIds: Types.ObjectId[] -): Promise { - const voteIds: Types.ObjectId[] = []; - - // Create votes for comments - const commentVotes = await createVotesForComments(commentIds, userIds); - voteIds.push(...commentVotes); - - return voteIds; -} diff --git a/libs/backend/src/seeds/factories/user.factory.ts b/libs/backend/src/seeds/factories/user.factory.ts deleted file mode 100644 index 75dd80b2..00000000 --- a/libs/backend/src/seeds/factories/user.factory.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { faker } from "@faker-js/faker"; -import User, { UserDocument } from "../../models/user/user.js"; -import { userRole, UserRole } from "types"; -import { Types } from "mongoose"; - -export interface UserFactoryOptions { - role?: UserRole; - username?: string; - email?: string; - reportCount?: number; - banCount?: number; -} - -/** - * Create a single user with realistic data - */ -export async function createUser( - options: UserFactoryOptions = {} -): Promise { - const username = options.username || faker.internet.username().toLowerCase(); - const email = - options.email || - faker.internet.email({ firstName: username }).toLowerCase(); - - const userData: Partial = { - username, - email, - password: "TestPassword123!", // Will be hashed by pre-save hook - role: options.role || userRole.USER, - reportCount: options.reportCount ?? faker.number.int({ min: 0, max: 5 }), - banCount: options.banCount ?? faker.number.int({ min: 0, max: 2 }), - profile: { - bio: faker.helpers.maybe(() => faker.lorem.sentence(), { - probability: 0.6 - }), - picture: faker.helpers.maybe(() => faker.image.avatar(), { - probability: 0.4 - }), - location: faker.helpers.maybe(() => faker.location.city(), { - probability: 0.5 - }), - socials: faker.helpers.maybe(() => [faker.internet.url()], { - probability: 0.3 - }) - } - }; - - const user = new User(userData); - await user.save(); - - return user._id as Types.ObjectId; -} - -/** - * Create multiple users with various roles - * - * Always creates: - * - 1 test user (username: "testuser", email: "test@codincod.com", password: "TestPassword123!") - * - 2-3 moderators (username: "moderator1", etc.) - * - Remaining users with random data - */ -export async function createUsers(count: number): Promise { - const userIds: Types.ObjectId[] = []; - - // Create test user with known credentials - userIds.push( - await createUser({ - username: "codincoder", - email: "codincoder@codincod.com", - role: userRole.USER, - reportCount: 0, - banCount: 0 - }) - ); - - // Create 2-3 moderators - const moderatorCount = faker.number.int({ min: 2, max: 3 }); - for (let i = 0; i < moderatorCount; i++) { - userIds.push( - await createUser({ - username: `moderator${i + 1}`, - email: `moderator${i + 1}@codincod.com`, - role: userRole.MODERATOR, - reportCount: 0, - banCount: 0 - }) - ); - } - - // Create regular users - const regularUserCount = count - userIds.length; - for (let i = 0; i < regularUserCount; i++) { - userIds.push(await createUser()); - } - - return userIds; -} diff --git a/libs/backend/src/seeds/index.ts b/libs/backend/src/seeds/index.ts deleted file mode 100644 index ba9eef7e..00000000 --- a/libs/backend/src/seeds/index.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { config } from "dotenv"; -config(); - -import { - connectToDatabase, - disconnectFromDatabase -} from "./utils/db-connection.js"; -import { clearDatabase, getCollectionCounts } from "./utils/clear-database.js"; -import { SeedLogger, getEnvNumber } from "./utils/seed-helpers.js"; -import { getSeedCounts } from "./config/seed-presets.js"; -import { createUsers } from "./factories/user.factory.js"; -import { createPuzzles } from "./factories/puzzle.factory.js"; -import { createSubmissions } from "./factories/submission.factory.js"; -import { createPuzzleComments } from "./factories/comment.factory.js"; -import { createUserBans } from "./factories/user-ban.factory.js"; -import { createMultiplePreferences } from "./factories/preferences.factory.js"; -import { createReports } from "./factories/report.factory.js"; -import { createGames } from "./factories/game.factory.js"; -import { createChatMessages } from "./factories/chat-message.factory.js"; -import { createUserVotes } from "./factories/user-vote.factory.js"; -import { userRole } from "types"; -import User from "../models/user/user.js"; -import Game from "../models/game/game.js"; -import { Types } from "mongoose"; -import { seedProgrammingLanguages } from "./programming-language.seed.js"; - -async function seed() { - console.log("🌱 Starting database seeding...\n"); - console.log("=".repeat(50)); - - try { - // Connect to database - await connectToDatabase(); - - // Clear existing data - await clearDatabase(process.argv.includes("--force")); - - // Seed programming languages from Piston (must be first!) - const langLogger = new SeedLogger( - "Seeding programming languages from Piston" - ); - const programmingLanguages = await seedProgrammingLanguages(); - langLogger.success(programmingLanguages.length, "programming languages"); - - // Get seed counts from environment or preset - const seedCounts = getSeedCounts(getEnvNumber); - const presetName = process.env.SEED_PRESET || "standard"; - - console.log("\n📊 Seed Configuration:"); - console.log(` Preset: ${presetName.toUpperCase()}`); - console.log(` Users: ${seedCounts.users}`); - console.log(` Puzzles: ${seedCounts.puzzles}`); - console.log( - ` Submissions: ~${seedCounts.puzzles * seedCounts.submissionsPerPuzzle}` - ); - console.log( - ` Comments: ~${seedCounts.puzzles * seedCounts.commentsPerPuzzle}` - ); - console.log(` Reports: ${seedCounts.reports}`); - console.log(` Games: ${seedCounts.games}\n`); - - // 1. Create Users (admin, moderators, regular users) - const userLogger = new SeedLogger("Creating users"); - const userIds = await createUsers(seedCounts.users); - userLogger.success(userIds.length, "users"); - - // 2. Create Preferences for users - const prefLogger = new SeedLogger("Creating user preferences"); - const preferencesIds = await createMultiplePreferences(userIds); - prefLogger.success(preferencesIds.length, "preferences"); - - // Get moderator IDs for later use - const moderators = await User.find({ role: userRole.MODERATOR }).lean(); - const moderatorIds = moderators.map( - (mod) => mod._id as unknown as Types.ObjectId - ); - - // 3. Create User Bans - const banLogger = new SeedLogger("Creating user bans"); - const banIds = await createUserBans(userIds, moderatorIds); - banLogger.success(banIds.length, "user bans"); - - // 4. Create Puzzles - const puzzleLogger = new SeedLogger("Creating puzzles"); - const puzzleIds = await createPuzzles(seedCounts.puzzles, userIds); - puzzleLogger.success(puzzleIds.length, "puzzles"); - - // 5. Create Submissions - const submissionLogger = new SeedLogger("Creating submissions"); - const submissionCount = - seedCounts.puzzles * seedCounts.submissionsPerPuzzle; - const submissionIds = await createSubmissions( - submissionCount, - userIds, - puzzleIds - ); - submissionLogger.success(submissionIds.length, "submissions"); - - // 6. Create Comments (with nested replies) - const commentLogger = new SeedLogger("Creating comments"); - const commentCount = seedCounts.puzzles * seedCounts.commentsPerPuzzle; - const commentIds = await createPuzzleComments( - commentCount, - userIds, - puzzleIds - ); - commentLogger.success(commentIds.length, "comments (+ nested replies)"); - - // 7. Create Reports - const reportLogger = new SeedLogger("Creating reports"); - const reportIds = await createReports( - seedCounts.reports, - userIds, - puzzleIds, - moderatorIds - ); - reportLogger.success(reportIds.length, "reports"); - - // 8. Create Games - const gameLogger = new SeedLogger("Creating games"); - const gameIds = await createGames(seedCounts.games, userIds, puzzleIds); - gameLogger.success(gameIds.length, "games"); - - // 9. Create Chat Messages for Games - const chatLogger = new SeedLogger("Creating chat messages"); - // Build a map of game IDs to player IDs - const games = await Game.find({ _id: { $in: gameIds } }).lean(); - const gamePlayerMap = new Map(); - games.forEach((game) => { - gamePlayerMap.set( - game._id.toString(), - game.players as unknown as Types.ObjectId[] - ); - }); - const chatMessageIds = await createChatMessages(gameIds, gamePlayerMap); - chatLogger.success(chatMessageIds.length, "chat messages"); - - // 10. Create User Votes - const voteLogger = new SeedLogger("Creating user votes"); - const voteIds = await createUserVotes(commentIds, userIds); - voteLogger.success(voteIds.length, "user votes"); - - // Display final counts - console.log("\n" + "=".repeat(50)); - console.log("📈 Final Database Counts:\n"); - - const counts = await getCollectionCounts(); - Object.entries(counts).forEach(([collection, count]) => { - console.log(` ${collection.padEnd(15)}: ${count}`); - }); - - console.log("\n" + "=".repeat(50)); - console.log("✨ Seeding completed successfully!\n"); - } catch (error) { - console.error("\n❌ Seeding failed:", error); - process.exit(1); - } finally { - await disconnectFromDatabase(); - } -} - -seed(); diff --git a/libs/backend/src/seeds/programming-language.seed.ts b/libs/backend/src/seeds/programming-language.seed.ts deleted file mode 100644 index 66bdb79c..00000000 --- a/libs/backend/src/seeds/programming-language.seed.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { arePistonRuntimes, PistonRuntime, pistonUrls } from "types"; -import ProgrammingLanguage from "../models/programming-language/language.js"; -import { buildPistonUri } from "@/utils/functions/build-piston-uri.js"; - -/** - * Seed programming languages from Piston runtimes - * This function fetches available runtimes from Piston and populates the ProgrammingLanguage collection - */ -export async function seedProgrammingLanguages() { - try { - console.log("Fetching available runtimes from Piston..."); - const response = await fetch(buildPistonUri(pistonUrls.RUNTIMES)); - const runtimes = await response.json(); - - if (!arePistonRuntimes(runtimes)) { - throw new Error("Failed to fetch Piston runtimes"); - } - - console.log(`Found ${runtimes.length} runtimes from Piston`); - - // Clear existing programming languages - await ProgrammingLanguage.deleteMany({}); - console.log("Cleared existing programming languages"); - - // Insert all runtimes as programming languages - const languageDocs = runtimes.map((runtime: PistonRuntime) => ({ - language: runtime.language, - version: runtime.version, - aliases: runtime.aliases || [], - runtime: runtime.runtime - })); - - const insertedLanguages = - await ProgrammingLanguage.insertMany(languageDocs); - console.log(`✓ Seeded ${insertedLanguages.length} programming languages`); - - return insertedLanguages; - } catch (error) { - console.error("Error seeding programming languages:", error); - throw error; - } -} diff --git a/libs/backend/src/seeds/utils/clear-database.ts b/libs/backend/src/seeds/utils/clear-database.ts deleted file mode 100644 index fccec895..00000000 --- a/libs/backend/src/seeds/utils/clear-database.ts +++ /dev/null @@ -1,75 +0,0 @@ -import User from "../../models/user/user.js"; -import Puzzle from "../../models/puzzle/puzzle.js"; -import Submission from "../../models/submission/submission.js"; -import Comment from "../../models/comment/comment.js"; -import Game from "../../models/game/game.js"; -import Report from "../../models/report/report.js"; -import Preferences from "../../models/preferences/preferences.js"; -import UserBan from "../../models/moderation/user-ban.js"; -import UserVote from "../../models/user/user-vote.js"; -import ChatMessage from "../../models/chat/chat-message.js"; -import readline from "readline/promises"; - -export async function clearDatabase(force = false): Promise { - if (!force) { - const counts = await getCollectionCounts(); - console.log("Current collection counts:", counts); - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - const answer = await rl.question( - "⚠️ This will DELETE ALL DATA from the database. Are you sure? (yes/no): " - ); - rl.close(); - - if (answer.toLowerCase() !== "yes") { - console.log("❌ Database clear cancelled"); - return; - } - } - - console.log("🗑️ Clearing database..."); - - try { - // Delete in reverse dependency order - await Promise.all([ - ChatMessage.deleteMany({}), - UserVote.deleteMany({}), - Report.deleteMany({}), - Game.deleteMany({}) - ]); - - await Promise.all([Comment.deleteMany({}), Submission.deleteMany({})]); - - await Puzzle.deleteMany({}); - - await Promise.all([UserBan.deleteMany({}), Preferences.deleteMany({})]); - - await User.deleteMany({}); - - console.log("✅ Database cleared successfully"); - } catch (error) { - console.error("❌ Error clearing database:", error); - throw error; - } -} - -export async function getCollectionCounts(): Promise> { - const counts = { - users: await User.countDocuments(), - puzzles: await Puzzle.countDocuments(), - submissions: await Submission.countDocuments(), - comments: await Comment.countDocuments(), - games: await Game.countDocuments(), - reports: await Report.countDocuments(), - preferences: await Preferences.countDocuments(), - userBans: await UserBan.countDocuments(), - userVotes: await UserVote.countDocuments(), - chatMessages: await ChatMessage.countDocuments() - }; - - return counts; -} diff --git a/libs/backend/src/seeds/utils/db-connection.ts b/libs/backend/src/seeds/utils/db-connection.ts deleted file mode 100644 index 0473a432..00000000 --- a/libs/backend/src/seeds/utils/db-connection.ts +++ /dev/null @@ -1,25 +0,0 @@ -import mongoose from "mongoose"; -import { config } from "dotenv"; -config(); - -export async function connectToDatabase(): Promise { - const mongoUri = process.env.MONGO_URI || "mongodb://localhost:27017"; - const dbName = process.env.MONGO_DB_NAME || "codincod"; - - try { - await mongoose.connect(mongoUri, { - dbName, - serverSelectionTimeoutMS: 5000, - socketTimeoutMS: 45000 - }); - - console.log(`✅ Connected to MongoDB: ${dbName}`); - } catch (error) { - console.error("❌ MongoDB connection error:", error); - throw error; - } -} -export async function disconnectFromDatabase(): Promise { - await mongoose.disconnect(); - console.log("✅ Disconnected from MongoDB"); -} diff --git a/libs/backend/src/seeds/utils/seed-helpers.ts b/libs/backend/src/seeds/utils/seed-helpers.ts deleted file mode 100644 index 371c9b4c..00000000 --- a/libs/backend/src/seeds/utils/seed-helpers.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { faker } from "@faker-js/faker"; - -/** - * Randomly select an item from an array - */ -export function randomFromArray(array: T[]): T { - return array[Math.floor(Math.random() * array.length)]; -} - -/** - * Randomly select multiple items from an array - */ -export function randomMultipleFromArray( - array: T[], - min: number, - max: number -): T[] { - const count = faker.number.int({ min, max }); - const shuffled = [...array].sort(() => 0.5 - Math.random()); - return shuffled.slice(0, Math.min(count, array.length)); -} - -/** - * Generate a random subset of an enum's values - */ -export function randomEnumValues>( - enumObj: T, - min = 1, - max = 3 -): string[] { - const values = Object.values(enumObj); - return randomMultipleFromArray(values, min, max); -} - -/** - * Weighted random boolean (default 50/50) - */ -export function randomBoolean(probability = 0.5): boolean { - return Math.random() < probability; -} - -/** - * Progress logger for seeding operations - */ -export class SeedLogger { - private startTime: number; - - constructor(private operation: string) { - this.startTime = Date.now(); - console.log(`\n🌱 ${operation}...`); - } - - success(count: number, itemType: string): void { - const duration = Date.now() - this.startTime; - console.log(`✅ Created ${count} ${itemType} (${duration}ms)`); - } - - error(error: unknown): void { - console.error(`❌ ${this.operation} failed:`, error); - } -} - -/** - * Get environment variable as number with fallback - */ -export function getEnvNumber(key: string, defaultValue: number): number { - const value = process.env[key]; - return value ? parseInt(value, 10) : defaultValue; -} diff --git a/libs/backend/src/services/game-mode.service.ts b/libs/backend/src/services/game-mode.service.ts deleted file mode 100644 index b04e16ca..00000000 --- a/libs/backend/src/services/game-mode.service.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { GameMode, gameModeEnum } from "types"; -import { - getGameModeConfig, - sortSubmissionsByGameMode, - type SubmissionData -} from "../utils/game-mode/game-mode-strategy.js"; -import { GameDocument } from "../models/game/game.js"; -import { SubmissionDocument } from "../models/submission/submission.js"; -import type { ObjectId } from "mongoose"; - -type PopulatedSubmission = Omit & { - user: ObjectId | { _id: ObjectId; username: string }; -}; - -function isPopulatedUser(user: ObjectId | { _id: ObjectId; username: string }): user is { _id: ObjectId; username: string } { - return typeof user === "object" && user !== null && "_id" in user && "username" in user; -} - -/** - * Service for game mode logic - * Handles scoring, ranking, and game mode-specific business logic - */ -export class GameModeService { - /** - * Calculate score for a submission based on game mode - */ - calculateSubmissionScore( - mode: GameMode, - submission: { - result: { successRate: number }; - createdAt: Date | string; - code: string; - gameStartTime: Date | string; - attempts?: number; - } - ): number { - const config = getGameModeConfig(mode); - const startTime = new Date(submission.gameStartTime).getTime(); - const submissionTime = new Date(submission.createdAt).getTime(); - const timeSpent = (submissionTime - startTime) / 1000; // Convert to seconds - - const submissionData: SubmissionData = { - successRate: submission.result.successRate, - timeSpent, - codeLength: submission.code.length, - attempts: submission.attempts ?? undefined - }; - - return config.calculateScore(submissionData); - } - - /** - * Get leaderboard for a game based on its mode - */ - getGameLeaderboard( - game: GameDocument, - submissions: Array - ): Array<{ - userId: string; - username: string; - score: number; - timeSpent: number; - codeLength?: number; - successRate: number; - rank: number; - }> { - const mode = game.options?.mode ?? gameModeEnum.FASTEST; - - const sortedSubmissions = sortSubmissionsByGameMode( - submissions, - mode, - game.createdAt - ); - - return sortedSubmissions.map((submission, index) => { - const userId = isPopulatedUser(submission.user) - ? submission.user._id.toString() - : submission.user.toString(); - - const username = isPopulatedUser(submission.user) - ? submission.user.username - : ""; - - const startTime = new Date(game.createdAt).getTime(); - const submissionTime = new Date(submission.createdAt).getTime(); - const timeSpent = (submissionTime - startTime) / 1000; - - const submissionData: SubmissionData = { - successRate: submission.result.successRate, - timeSpent, - codeLength: submission.code?.length ?? 0, - attempts: submission.attempts - }; - - const config = getGameModeConfig(mode); - const score = config.calculateScore(submissionData); - - return { - userId, - username, - score, - timeSpent, - codeLength: submission.code?.length ?? 0, - successRate: submission.result.successRate, - rank: index + 1 - }; - }); - } - - /** - * Get display metrics for a game mode - */ - getDisplayMetricsForMode(mode: GameMode): string[] { - const config = getGameModeConfig(mode); - return config.displayMetrics; - } - - /** - * Validate submission based on game mode rules - */ - validateSubmissionForMode( - mode: GameMode, - submission: { - result: { successRate: number }; - attempts?: number; - } - ): { valid: boolean; reason?: string } { - switch (mode) { - case gameModeEnum.HARDCORE: - // Hardcore mode: only one attempt allowed - if ((submission.attempts ?? 1) > 1) { - return { - valid: false, - reason: - "Hardcore mode allows only one attempt. This submission has multiple attempts." - }; - } - break; - - case gameModeEnum.BACKWARDS: - case gameModeEnum.DEBUG: - break; - - case gameModeEnum.INCREMENTAL: - // Incremental mode accepts partial success - if (submission.result.successRate === 0) { - return { - valid: false, - reason: "Incremental mode requires at least partial success." - }; - } - break; - - case gameModeEnum.FASTEST: - case gameModeEnum.SHORTEST: - case gameModeEnum.EFFICIENCY: - case gameModeEnum.TYPERACER: - case gameModeEnum.RANDOM: - default: - // Most modes require full success - if (submission.result.successRate < 1) { - return { - valid: false, - reason: "Submission must pass all test cases for this game mode." - }; - } - } - - return { valid: true }; - } - - /** - * Get game mode description for UI - */ - getGameModeDescription(mode: GameMode): string { - switch (mode) { - case gameModeEnum.FASTEST: - return "Complete the puzzle in the shortest time"; - case gameModeEnum.SHORTEST: - return "Write the solution with the fewest characters"; - case gameModeEnum.BACKWARDS: - return "Work from output to input - logical deduction challenge"; - case gameModeEnum.HARDCORE: - return "One attempt only - no test runs allowed"; - case gameModeEnum.DEBUG: - return "Fix broken code with minimal changes"; - case gameModeEnum.EFFICIENCY: - return "Write the most computationally efficient solution"; - case gameModeEnum.TYPERACER: - return "Copy code perfectly at maximum speed"; - case gameModeEnum.INCREMENTAL: - return "Progressive requirements - solve step by step"; - case gameModeEnum.RANDOM: - return "Random mode - a surprise challenge"; - default: - return "Complete the challenge"; - } - } - - /** - * Resolve random mode to actual mode - */ - resolveGameMode(mode: GameMode): GameMode { - if (mode !== gameModeEnum.RANDOM) { - return mode; - } - - // Select a random mode (excluding RANDOM itself) - const modes = Object.values(gameModeEnum).filter( - (m) => m !== gameModeEnum.RANDOM - ); - const randomIndex = Math.floor(Math.random() * modes.length); - return modes[randomIndex] as GameMode; - } -} - -// Export singleton instance -export const gameModeService = new GameModeService(); diff --git a/libs/backend/src/services/game.service.ts b/libs/backend/src/services/game.service.ts deleted file mode 100644 index 082c82e8..00000000 --- a/libs/backend/src/services/game.service.ts +++ /dev/null @@ -1,174 +0,0 @@ -import Game, { GameDocument } from "../models/game/game.js"; -import { GameEntity, ObjectId } from "types"; - -/** - * Service for Game database operations - * Centralizes all MongoDB queries for games - */ -export class GameService { - /** - * Find a game by ID with all related data populated - */ - async findByIdPopulated(id: string | ObjectId): Promise { - return await Game.findById(id) - .populate("owner") - .populate("players") - .populate({ - path: "playerSubmissions", - populate: [{ path: "user" }, { path: "programmingLanguage" }] - }) - .exec(); - } - - /** - * Find a game by ID without population - */ - async findById(id: string | ObjectId): Promise { - return await Game.findById(id).exec(); - } - - /** - * Create a new game - */ - async create(gameEntity: GameEntity): Promise { - const game = new Game(gameEntity); - return await game.save(); - } - - /** - * Update a game's player submissions - */ - async addPlayerSubmission( - gameId: string | ObjectId, - submissionId: string | ObjectId - ): Promise { - const game = await Game.findById(gameId); - if (!game) return null; - - const uniqueSubmissions = new Set([ - ...(game.playerSubmissions ?? []), - submissionId.toString() - ]); - game.playerSubmissions = Array.from(uniqueSubmissions); - - return await game.save(); - } - - /** - * Add a player to a game with optimistic locking (prevents race conditions) - * @throws Error if version mismatch occurs (game was modified by another request) - */ - async addPlayer( - gameId: string | ObjectId, - playerId: string | ObjectId, - expectedVersion: number - ): Promise { - const result = await Game.findOneAndUpdate( - { - _id: gameId, - version: expectedVersion, - players: { $ne: playerId } - }, - { - $push: { players: playerId }, - $inc: { version: 1 } - }, - { new: true } - ).exec(); - - return result; - } - - /** - * Find games by player ID - */ - async findByPlayerId( - playerId: string | ObjectId, - options?: { - limit?: number; - skip?: number; - sort?: Record; - } - ): Promise { - let query = Game.find({ players: playerId }); - - if (options?.sort) { - query = query.sort(options.sort); - } - if (options?.skip) { - query = query.skip(options.skip); - } - if (options?.limit) { - query = query.limit(options.limit); - } - - return await query.exec(); - } - - /** - * Find games by owner ID - */ - async findByOwnerId( - ownerId: string | ObjectId, - options?: { - limit?: number; - skip?: number; - sort?: Record; - } - ): Promise { - let query = Game.find({ owner: ownerId }); - - if (options?.sort) { - query = query.sort(options.sort); - } - if (options?.skip) { - query = query.skip(options.skip); - } - if (options?.limit) { - query = query.limit(options.limit); - } - - return await query.exec(); - } - - /** - * Find all games with optional filters - */ - async findAll(options?: { - filter?: Record; - limit?: number; - skip?: number; - sort?: Record; - }): Promise { - let query = Game.find(options?.filter ?? {}); - - if (options?.sort) { - query = query.sort(options.sort); - } - if (options?.skip) { - query = query.skip(options.skip); - } - if (options?.limit) { - query = query.limit(options.limit); - } - - return await query.exec(); - } - - /** - * Count games matching a filter - */ - async count(filter?: Record): Promise { - return await Game.countDocuments(filter ?? {}); - } - - /** - * Delete a game by ID - */ - async deleteById(id: string | ObjectId): Promise { - return await Game.findByIdAndDelete(id).exec(); - } -} - -// Export a singleton instance -export const gameService = new GameService(); diff --git a/libs/backend/src/services/leaderboard.service.ts b/libs/backend/src/services/leaderboard.service.ts deleted file mode 100644 index 74b555d3..00000000 --- a/libs/backend/src/services/leaderboard.service.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { GameMode, gameModeEnum } from "types"; -import UserMetrics, { - UserMetricsDocument -} from "../models/user-metrics/user-metrics.js"; -import Game, { GameDocument } from "../models/game/game.js"; -import Submission from "../models/submission/submission.js"; -import User from "../models/user/user.js"; -import { gameModeService } from "./game-mode.service.js"; -import { - calculateNewRating, - getDefaultRating, - type GlickoRating -} from "../utils/rating/glicko.js"; - -/** - * Helper to ensure glicko rating has Date objects instead of strings - */ -function normalizeGlickoRating(rating: any): GlickoRating { - return { - ...rating, - lastUpdated: - rating.lastUpdated instanceof Date - ? rating.lastUpdated - : new Date(rating.lastUpdated) - }; -} - -/** - * Service for calculating and managing leaderboards - * Processes games incrementally to update player ratings and rankings - */ -export class LeaderboardService { - /** - * Get or create user metrics document - */ - async getUserMetrics(userId: string): Promise { - let metrics = await UserMetrics.findOne({ userId }); - - if (!metrics) { - metrics = new UserMetrics({ - userId, - totalGamesPlayed: 0, - totalGamesWon: 0, - lastProcessedGameDate: new Date(0), - lastCalculationDate: new Date() - }); - await metrics.save(); - } - - return metrics; - } - - /** - * Process all completed games since last update for a specific game mode - */ - async processGamesForMode(mode: GameMode): Promise { - // Get all user metrics to find the earliest lastProcessedGameDate - const allMetrics = await UserMetrics.find({}); - const earliestProcessedDate = allMetrics.reduce((earliest, metric) => { - const modeMetrics = metric[mode as keyof UserMetricsDocument]; - if (!modeMetrics || typeof modeMetrics !== "object") return earliest; - - const lastGameDate = modeMetrics.lastGameDate; - if (!lastGameDate) return earliest; - - return lastGameDate < earliest ? lastGameDate : earliest; - }, new Date(0)); - - // Find completed games since last processed date - const completedGames = await Game.find({ - "options.mode": mode, - status: "completed", - createdAt: { $gt: earliestProcessedDate } - }) - .populate("players") - .sort({ createdAt: 1 }) - .exec(); - - let processedCount = 0; - - for (const game of completedGames) { - await this.processGameResults(game, mode); - processedCount++; - } - - if (processedCount > 0) { - await this.updateRankingsForMode(mode); - } - - return processedCount; - } - - async processGameResults(game: GameDocument, mode: GameMode): Promise { - if (!game.playerSubmissions || game.playerSubmissions.length === 0) { - return; - } - - const submissions = await Submission.find({ - _id: { $in: game.playerSubmissions } - }) - .populate("user") - .select("+code") - .exec(); - - if (submissions.length < 2) { - return; - } - - const leaderboard = gameModeService.getGameLeaderboard(game, submissions); - - if (leaderboard.length === 0) return; - - const winner = leaderboard[0]; - - for (let i = 0; i < leaderboard.length; i++) { - const entry = leaderboard[i]; - const userId = entry.userId; - - if (!userId) continue; - - const metrics = await this.getUserMetrics(userId); - - if (!metrics[mode]) { - metrics[mode] = { - averageScore: 0, - bestScore: 0, - gamesPlayed: 0, - gamesWon: 0, - glickoRating: getDefaultRating(), - totalScore: 0 - }; - } - - const modeMetrics = metrics[mode]; - const isWinner = entry.userId === winner.userId; - - modeMetrics.gamesPlayed += 1; - if (isWinner) { - modeMetrics.gamesWon += 1; - } - - modeMetrics.totalScore += entry.score; - modeMetrics.averageScore = - modeMetrics.totalScore / modeMetrics.gamesPlayed; - - if (entry.score > modeMetrics.bestScore) { - modeMetrics.bestScore = entry.score; - } - - const currentRating: GlickoRating = normalizeGlickoRating( - modeMetrics.glickoRating - ); - const games = leaderboard - .filter((opp) => opp.userId !== userId) - .map((opponent) => { - const opponentMetrics = metrics[mode]; - const opponentRating: GlickoRating = opponentMetrics?.glickoRating - ? normalizeGlickoRating(opponentMetrics.glickoRating) - : getDefaultRating(); - - const playerWon = entry.rank < opponent.rank; - - return { opponentRating, playerWon }; - }); - - if (games.length > 0) { - const newRating = calculateNewRating( - currentRating, - games.map((g) => ({ - opponentRating: g.opponentRating.rating, - opponentRd: g.opponentRating.rd, - score: g.playerWon ? 1 : 0 - })) - ); - - modeMetrics.glickoRating = newRating; - } - - modeMetrics.lastGameDate = game.createdAt; - - metrics.totalGamesPlayed += 1; - if (isWinner) { - metrics.totalGamesWon += 1; - } - - metrics.lastProcessedGameDate = game.createdAt; - metrics.lastCalculationDate = new Date(); - - await metrics.save(); - } - } - - /** - * Update rankings for all players in a game mode - */ - async updateRankingsForMode(mode: GameMode): Promise { - const modeField = `${mode}.glickoRating.rating`; - - // Get all users sorted by rating for this mode - const sortedMetrics = await UserMetrics.find({ - [mode]: { $exists: true } - }) - .sort({ [modeField]: -1 }) - .exec(); - - // Update ranks - for (let i = 0; i < sortedMetrics.length; i++) { - const metrics = sortedMetrics[i]; - const modeMetrics = metrics[mode]; - - if (modeMetrics) { - modeMetrics.rank = i + 1; - await metrics.save(); - } - } - } - - /** - * Recalculate all leaderboards (called by cron job) - */ - async recalculateAllLeaderboards(): Promise<{ - processedGames: Record; - totalProcessed: number; - }> { - const modes = Object.values(gameModeEnum); - const results: Record = {} as Record; - let totalProcessed = 0; - - for (const mode of modes) { - const count = await this.processGamesForMode(mode); - results[mode] = count; - totalProcessed += count; - } - - return { processedGames: results, totalProcessed }; - } - - /** - * Get leaderboard entries for a specific game mode - */ - async getLeaderboard( - mode: GameMode, - page: number = 1, - pageSize: number = 50 - ): Promise<{ - entries: Array<{ - rank: number; - userId: string; - username: string; - rating: number; - glicko: GlickoRating; - gamesPlayed: number; - gamesWon: number; - winRate: number; - bestScore: number; - averageScore: number; - }>; - total: number; - lastUpdated: Date; - }> { - const skip = (page - 1) * pageSize; - const modeField = `${mode}.glickoRating.rating`; - - const metrics = await UserMetrics.find({ - [mode]: { $exists: true } - }) - .sort({ [modeField]: -1 }) - .skip(skip) - .limit(pageSize) - .populate("userId", "username") - .exec(); - - const total = await UserMetrics.countDocuments({ - [mode]: { $exists: true } - }); - - const entries = await Promise.all( - metrics.map(async (metric) => { - const modeMetrics = metric[mode]; - const user = await User.findById(metric.userId); - - if (!modeMetrics) { - throw new Error("Mode metrics not found for user"); - } - - return { - rank: modeMetrics.rank || 0, - userId: metric.userId.toString(), - username: user?.username || "Unknown", - rating: modeMetrics.glickoRating.rating, - glicko: normalizeGlickoRating(modeMetrics.glickoRating), - gamesPlayed: modeMetrics.gamesPlayed, - gamesWon: modeMetrics.gamesWon, - winRate: - modeMetrics.gamesPlayed > 0 - ? modeMetrics.gamesWon / modeMetrics.gamesPlayed - : 0, - bestScore: modeMetrics.bestScore, - averageScore: modeMetrics.averageScore - }; - }) - ); - - // Find most recent calculation date - const mostRecent = metrics.reduce((latest: Date, m) => { - const calcDate = - m.lastCalculationDate instanceof Date - ? m.lastCalculationDate - : new Date(m.lastCalculationDate); - return calcDate > latest ? calcDate : latest; - }, new Date(0)); - - return { - entries, - total, - lastUpdated: mostRecent - }; - } - - /** - * Get user's rankings across all game modes - */ - async getUserRankings(userId: string): Promise< - Record< - GameMode, - { - rank?: number; - rating: number; - gamesPlayed: number; - winRate: number; - } - > - > { - const metrics = await this.getUserMetrics(userId); - const modes = Object.values(gameModeEnum); - const rankings: any = {}; - - for (const mode of modes) { - const modeMetrics = metrics[mode]; - - if (modeMetrics) { - rankings[mode] = { - rank: modeMetrics.rank, - rating: modeMetrics.glickoRating.rating, - gamesPlayed: modeMetrics.gamesPlayed, - winRate: - modeMetrics.gamesPlayed > 0 - ? modeMetrics.gamesWon / modeMetrics.gamesPlayed - : 0 - }; - } - } - - return rankings; - } -} - -// Export singleton instance -export const leaderboardService = new LeaderboardService(); diff --git a/libs/backend/src/services/programming-language.service.ts b/libs/backend/src/services/programming-language.service.ts deleted file mode 100644 index 0dffb8ad..00000000 --- a/libs/backend/src/services/programming-language.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import ProgrammingLanguage, { - ProgrammingLanguageDocument -} from "../models/programming-language/language.js"; -import { - ObjectId, - ProgrammingLanguageDto, - programmingLanguageDtoSchema -} from "types"; - -/** - * Service for ProgrammingLanguage database operations - * Centralizes all MongoDB queries for programming languages - */ -export class ProgrammingLanguageService { - /** - * Find a programming language by ID - */ - async findById( - id: string | ObjectId - ): Promise { - return (await ProgrammingLanguage.findById( - id - ).lean()) as ProgrammingLanguageDocument | null; - } - - /** - * Find all programming languages - */ - async findAll(): Promise { - return await ProgrammingLanguage.find() - .select("-createdAt -updatedAt -__v") - .sort({ language: 1, version: -1 }); - } - - /** - * Find programming language by language name and version - */ - async findByLanguageAndVersion( - language: string, - version: string - ): Promise { - return await ProgrammingLanguage.findOne({ language, version }); - } - - /** - * Convert a ProgrammingLanguageDocument to DTO - */ - toDto(doc: ProgrammingLanguageDocument): ProgrammingLanguageDto { - return programmingLanguageDtoSchema.parse({ - _id: doc._id.toString(), - language: doc.language, - version: doc.version, - aliases: doc.aliases, - runtime: doc.runtime - }); - } - - /** - * Get all programming languages as DTOs - */ - async findAllAsDto(): Promise { - const languages = await this.findAll(); - return languages.map((lang) => this.toDto(lang)); - } - - /** - * Count all programming languages - */ - async count(): Promise { - return await ProgrammingLanguage.countDocuments({}); - } - - /** - * Create a new programming language - */ - async create(data: { - language: string; - version: string; - aliases?: string[]; - runtime?: string; - }): Promise { - const programmingLanguage = new ProgrammingLanguage(data); - return await programmingLanguage.save(); - } - - /** - * Create multiple programming languages - */ - async createMany( - data: Array<{ - language: string; - version: string; - aliases?: string[]; - runtime?: string; - }> - ): Promise { - return (await ProgrammingLanguage.insertMany( - data - )) as ProgrammingLanguageDocument[]; - } - - /** - * Delete all programming languages - */ - async deleteAll(): Promise { - await ProgrammingLanguage.deleteMany({}); - } -} - -// Export a singleton instance -export const programmingLanguageService = new ProgrammingLanguageService(); diff --git a/libs/backend/src/services/puzzle.service.ts b/libs/backend/src/services/puzzle.service.ts deleted file mode 100644 index c9d87226..00000000 --- a/libs/backend/src/services/puzzle.service.ts +++ /dev/null @@ -1,176 +0,0 @@ -import Puzzle, { PuzzleDocument } from "../models/puzzle/puzzle.js"; -import { ObjectId, PuzzleDto, PuzzleEntity, puzzleVisibilityEnum } from "types"; -import { PipelineStage } from "mongoose"; - -/** - * Service for Puzzle database operations - * Centralizes all MongoDB queries for puzzles - */ -export class PuzzleService { - /** - * Find a puzzle by ID - */ - async findById(id: string | ObjectId): Promise { - return await Puzzle.findById(id).exec(); - } - - /** - * Find a puzzle by ID with author and comments populated - */ - async findByIdPopulated( - id: string | ObjectId - ): Promise { - return await Puzzle.findById(id) - .populate("author") - .populate({ - path: "comments", - populate: { path: "author" } - }) - .exec(); - } - - /** - * Find random approved puzzles - */ - async findRandomApproved(count: number = 1): Promise { - const pipeline: PipelineStage[] = [ - { $match: { visibility: puzzleVisibilityEnum.APPROVED } }, - { $sample: { size: count } } - ]; - - return await Puzzle.aggregate(pipeline).exec(); - } - - /** - * Create a new puzzle - */ - async create(puzzleEntity: PuzzleEntity): Promise { - const puzzle = new Puzzle(puzzleEntity); - return await puzzle.save(); - } - - /** - * Update a puzzle by ID - */ - async updateById( - id: string | ObjectId, - update: Partial - ): Promise { - return await Puzzle.findByIdAndUpdate(id, update, { - new: true, - runValidators: true - }).exec(); - } - - /** - * Find puzzles by author ID - */ - async findByAuthorId( - authorId: string | ObjectId, - options?: { - visibility?: string; - limit?: number; - skip?: number; - sort?: Record; - } - ): Promise { - const filter: Record = { author: authorId }; - if (options?.visibility) { - filter.visibility = options.visibility; - } - - let query = Puzzle.find(filter); - - if (options?.sort) { - query = query.sort(options.sort); - } - if (options?.skip) { - query = query.skip(options.skip); - } - if (options?.limit) { - query = query.limit(options.limit); - } - - return await query.exec(); - } - - /** - * Find all puzzles with optional filters - */ - async findAll(options?: { - filter?: Record; - limit?: number; - skip?: number; - sort?: Record; - populate?: string | string[]; - }): Promise { - let query = Puzzle.find(options?.filter ?? {}); - - if (options?.sort) { - query = query.sort(options.sort); - } - if (options?.skip) { - query = query.skip(options.skip); - } - if (options?.limit) { - query = query.limit(options.limit); - } - if (options?.populate) { - query = query.populate(options.populate); - } - - return await query.exec(); - } - - /** - * Count puzzles matching a filter - */ - async count(filter?: Record): Promise { - return await Puzzle.countDocuments(filter ?? {}); - } - - /** - * Delete a puzzle by ID - */ - async deleteById(id: string | ObjectId): Promise { - return await Puzzle.findByIdAndDelete(id).exec(); - } - - /** - * Find puzzles with pagination - */ - async findWithPagination( - page: number, - pageSize: number, - filter?: Record, - sort?: Record - ): Promise<{ - puzzles: PuzzleDocument[]; - total: number; - page: number; - pageSize: number; - totalPages: number; - }> { - const skip = (page - 1) * pageSize; - const [puzzles, total] = await Promise.all([ - this.findAll({ - ...(filter && { filter }), - skip, - limit: pageSize, - ...(sort && { sort }) - }), - this.count(filter) - ]); - - return { - puzzles, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize) - }; - } -} - -// Export a singleton instance -export const puzzleService = new PuzzleService(); diff --git a/libs/backend/src/services/submission.service.ts b/libs/backend/src/services/submission.service.ts deleted file mode 100644 index 9e791358..00000000 --- a/libs/backend/src/services/submission.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import Submission, { - SubmissionDocument -} from "../models/submission/submission.js"; -import { ObjectId, SubmissionEntity } from "types"; - -export class SubmissionService { - async findById(id: string | ObjectId): Promise { - return await Submission.findById(id); - } - - async findByIdWithCode( - id: string | ObjectId - ): Promise { - return await Submission.findById(id).select("+code"); - } - - async findByIdPopulated( - id: string | ObjectId - ): Promise { - return await Submission.findById(id) - .populate("user") - .populate("programmingLanguage") - .populate("puzzle"); - } - - async findByIdWithCodePopulated( - id: string | ObjectId - ): Promise { - return await Submission.findById(id) - .select("+code") - .populate("user") - .populate("programmingLanguage") - .populate("puzzle"); - } - - async findByUser( - userId: string | ObjectId, - limit?: number - ): Promise { - const query = Submission.find({ user: userId }) - .sort({ createdAt: -1 }) - .populate("puzzle") - .populate("programmingLanguage"); - - if (limit) { - query.limit(limit); - } - - return await query.exec(); - } - - async findByPuzzle( - puzzleId: string | ObjectId - ): Promise { - return await Submission.find({ puzzle: puzzleId }) - .populate("user") - .populate("programmingLanguage") - .sort({ createdAt: -1 }); - } - - async create(data: SubmissionEntity): Promise { - const submission = new Submission(data); - return await submission.save(); - } - - async countByUser(userId: string | ObjectId): Promise { - return await Submission.countDocuments({ user: userId }); - } - - async countByPuzzle(puzzleId: string | ObjectId): Promise { - return await Submission.countDocuments({ puzzle: puzzleId }); - } - - async findSuccessfulByUser( - userId: string | ObjectId - ): Promise { - return await Submission.find({ - user: userId, - "result.successRate": 1 - }) - .populate("puzzle") - .populate("programmingLanguage") - .sort({ createdAt: -1 }); - } - - async deleteMany(ids: (string | ObjectId)[]): Promise { - await Submission.deleteMany({ _id: { $in: ids } }); - } - - async deleteByPuzzle(puzzleId: string | ObjectId): Promise { - await Submission.deleteMany({ puzzle: puzzleId }); - } - - async deleteByUser(userId: string | ObjectId): Promise { - await Submission.deleteMany({ user: userId }); - } -} - -export const submissionService = new SubmissionService(); diff --git a/libs/backend/src/services/user.service.ts b/libs/backend/src/services/user.service.ts deleted file mode 100644 index 26f68a1c..00000000 --- a/libs/backend/src/services/user.service.ts +++ /dev/null @@ -1,80 +0,0 @@ -import User, { UserDocument } from "../models/user/user.js"; -import { ObjectId, UserDto, UserEntity } from "types"; - -export class UserService { - async findById(id: string | ObjectId): Promise { - return await User.findById(id); - } - - async findByIdWithBan(id: string | ObjectId): Promise { - return await User.findById(id).populate("currentBan"); - } - - async findByUsername(username: string): Promise { - return await User.findOne({ username }); - } - - async findByEmail(email: string): Promise { - return await User.findOne({ email }).select("+email"); - } - - async findByUsernameWithPassword( - username: string - ): Promise { - return await User.findOne({ username }).select("+password"); - } - - async create( - data: Omit - ): Promise { - const user = new User(data); - return await user.save(); - } - - async updateProfile( - id: string | ObjectId, - profile: Partial - ): Promise { - return await User.findByIdAndUpdate( - id, - { $set: { profile } }, - { new: true } - ); - } - - async usernameExists(username: string): Promise { - const count = await User.countDocuments({ username }); - return count > 0; - } - - async emailExists(email: string): Promise { - const count = await User.countDocuments({ email }); - return count > 0; - } - - async updateBan( - userId: string | ObjectId, - banId: ObjectId | null - ): Promise { - await User.findByIdAndUpdate(userId, { currentBan: banId }); - } - - async incrementReportCount(userId: string | ObjectId): Promise { - await User.findByIdAndUpdate(userId, { $inc: { reportCount: 1 } }); - } - - async findMany(ids: (string | ObjectId)[]): Promise { - return await User.find({ _id: { $in: ids } }); - } - - toDto(user: UserDocument): UserDto { - return { - _id: (user._id as ObjectId).toString(), - username: user.username, - profile: user.profile, - createdAt: user.createdAt - }; - } -} - -export const userService = new UserService(); diff --git a/libs/backend/src/static/index.html b/libs/backend/src/static/index.html deleted file mode 100644 index 2a847134..00000000 --- a/libs/backend/src/static/index.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - Fastify + Typescript App - - - - -

Welcome to Fastify + Typescript App 🔥

-

API Documentation

-
- - - - diff --git a/libs/backend/src/tests/execute.test.ts b/libs/backend/src/tests/execute.test.ts deleted file mode 100644 index 47844943..00000000 --- a/libs/backend/src/tests/execute.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, beforeAll, afterAll, it, expect, vi } from "vitest"; -import fastify, { FastifyInstance } from "fastify"; -import router from "@/router.js"; -import { backendUrls, httpRequestMethod, httpResponseCodes } from "types"; -import { executionResponseErrors } from "@/routes/execute/index.js"; - -vi.mock("@/plugins/middleware/authenticated.js", () => ({ - default: vi.fn((request, _reply, done) => { - request.user = { - userId: "test-user-id", - username: "test-user" - }; - done(); - }) -})); - -describe("Execute Endpoint", () => { - let app: FastifyInstance; - - beforeAll(async () => { - app = fastify(); - await app.register(router); - await app.ready(); - }); - - afterAll(async () => { - await app.close(); - }); - - const tests = [ - { - name: 'execute with "unknown" language', - payload: { - code: "print('hi')", - language: "unknown", - testInput: "", - testOutput: "hi" - }, - expectedStatus: httpResponseCodes.CLIENT_ERROR.BAD_REQUEST, - expectedResponse: executionResponseErrors.UNSUPPORTED_LANGUAGE - } - ]; - - it.each(tests)( - "Test: $name", - async ({ payload, expectedStatus, expectedResponse }) => { - const response = await app.inject({ - method: httpRequestMethod.POST, - url: backendUrls.EXECUTE, - payload - }); - - expect(response.statusCode).toBe(expectedStatus); - expect(response.json()).toEqual(expectedResponse); - } - ); -}); diff --git a/libs/backend/src/tests/game-mode-strategy.test.ts b/libs/backend/src/tests/game-mode-strategy.test.ts deleted file mode 100644 index 0a7fb6ea..00000000 --- a/libs/backend/src/tests/game-mode-strategy.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - calculateScore, - getGameModeConfig, - sortSubmissionsByGameMode, - type SubmissionData -} from "../utils/game-mode/game-mode-strategy.js"; -import { gameModeEnum } from "types"; - -describe("Game Mode Strategy", () => { - describe("FASTEST mode", () => { - it("should give higher score to faster completions", () => { - const fast: SubmissionData = { - successRate: 1, - timeSpent: 10 - }; - const slow: SubmissionData = { - successRate: 1, - timeSpent: 20 - }; - - const fastScore = calculateScore(gameModeEnum.FASTEST, fast); - const slowScore = calculateScore(gameModeEnum.FASTEST, slow); - - expect(fastScore).toBeGreaterThan(slowScore); - }); - - it("should return 0 score for failed submissions", () => { - const failed: SubmissionData = { - successRate: 0.5, - timeSpent: 10 - }; - - expect(calculateScore(gameModeEnum.FASTEST, failed)).toBe(0); - }); - }); - - describe("SHORTEST mode", () => { - it("should give higher score to shorter code", () => { - const short: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 50 - }; - const long: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 100 - }; - - const shortScore = calculateScore(gameModeEnum.SHORTEST, short); - const longScore = calculateScore(gameModeEnum.SHORTEST, long); - - expect(shortScore).toBeGreaterThan(longScore); - }); - - it("should return 0 for missing code length", () => { - const noLength: SubmissionData = { - successRate: 1, - timeSpent: 10 - }; - - expect(calculateScore(gameModeEnum.SHORTEST, noLength)).toBe(0); - }); - }); - - describe("BACKWARDS mode", () => { - it("should penalize multiple attempts", () => { - const oneAttempt: SubmissionData = { - successRate: 1, - timeSpent: 10, - attempts: 1 - }; - const multipleAttempts: SubmissionData = { - successRate: 1, - timeSpent: 10, - attempts: 5 - }; - - const oneScore = calculateScore(gameModeEnum.BACKWARDS, oneAttempt); - const multiScore = calculateScore( - gameModeEnum.BACKWARDS, - multipleAttempts - ); - - expect(oneScore).toBeGreaterThan(multiScore); - }); - }); - - describe("HARDCORE mode", () => { - it("should give score only to first-attempt successes", () => { - const firstTry: SubmissionData = { - successRate: 1, - timeSpent: 10, - attempts: 1 - }; - const secondTry: SubmissionData = { - successRate: 1, - timeSpent: 10, - attempts: 2 - }; - - const firstScore = calculateScore(gameModeEnum.HARDCORE, firstTry); - const secondScore = calculateScore(gameModeEnum.HARDCORE, secondTry); - - expect(firstScore).toBeGreaterThan(0); - expect(secondScore).toBe(0); - }); - }); - - describe("DEBUG mode", () => { - it("should reward fewer code changes", () => { - const smallChange: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 10 - }; - const largeChange: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 100 - }; - - const smallScore = calculateScore(gameModeEnum.DEBUG, smallChange); - const largeScore = calculateScore(gameModeEnum.DEBUG, largeChange); - - expect(smallScore).toBeGreaterThan(largeScore); - }); - }); - - describe("EFFICIENCY mode", () => { - it("should balance time and code length", () => { - const efficient: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 50 - }; - const inefficient: SubmissionData = { - successRate: 1, - timeSpent: 50, - codeLength: 200 - }; - - const efficientScore = calculateScore(gameModeEnum.EFFICIENCY, efficient); - const inefficientScore = calculateScore( - gameModeEnum.EFFICIENCY, - inefficient - ); - - expect(efficientScore).toBeGreaterThan(inefficientScore); - }); - - it("should weight code length more heavily (60%) than time (40%)", () => { - const shortSlow: SubmissionData = { - successRate: 1, - timeSpent: 100, - codeLength: 50 - }; - const longFast: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 500 - }; - - const shortScore = calculateScore(gameModeEnum.EFFICIENCY, shortSlow); - const longScore = calculateScore(gameModeEnum.EFFICIENCY, longFast); - - // With 60% weight on length, shorter code with slower time should still win - // shortScore = 1000000/100 * 0.4 + 500000/50 * 0.6 = 4000 + 6000 = 10000 - // longScore = 1000000/10 * 0.4 + 500000/500 * 0.6 = 40000 + 600 = 40600 - // Actually longScore wins because time difference is too large - // This test was incorrect - efficiency balances both factors - expect(longScore).toBeGreaterThan(shortScore); - }); - }); - - describe("TYPERACER mode", () => { - it("should calculate typing speed (chars per second)", () => { - const fast: SubmissionData = { - successRate: 1, - timeSpent: 10, - codeLength: 200 // 20 chars/sec - }; - const slow: SubmissionData = { - successRate: 1, - timeSpent: 20, - codeLength: 200 // 10 chars/sec - }; - - const fastScore = calculateScore(gameModeEnum.TYPERACER, fast); - const slowScore = calculateScore(gameModeEnum.TYPERACER, slow); - - expect(fastScore).toBeGreaterThan(slowScore); - expect(fastScore).toBeCloseTo(20 * 1000, 0); - expect(slowScore).toBeCloseTo(10 * 1000, 0); - }); - - it("should return 0 for missing code length", () => { - const noLength: SubmissionData = { - successRate: 1, - timeSpent: 10 - }; - - expect(calculateScore(gameModeEnum.TYPERACER, noLength)).toBe(0); - }); - }); - - describe("INCREMENTAL mode", () => { - it("should reward earlier completion with time decay", () => { - const early: SubmissionData = { - successRate: 1, - timeSpent: 60 // 1 minute - }; - const late: SubmissionData = { - successRate: 1, - timeSpent: 1800 // 30 minutes - }; - - const earlyScore = calculateScore(gameModeEnum.INCREMENTAL, early); - const lateScore = calculateScore(gameModeEnum.INCREMENTAL, late); - - expect(earlyScore).toBeGreaterThan(lateScore); - }); - - it("should have minimum decay factor of 0.1", () => { - const veryLate: SubmissionData = { - successRate: 1, - timeSpent: 7200 // 2 hours (way past 1 hour window) - }; - - const score = calculateScore(gameModeEnum.INCREMENTAL, veryLate); - expect(score).toBeGreaterThan(0); // Should still have 0.1 factor - expect(score).toBeCloseTo(1000000 * 0.1, -4); - }); - - it("should allow partial success", () => { - const partial: SubmissionData = { - successRate: 0.6, - timeSpent: 100 - }; - - const score = calculateScore(gameModeEnum.INCREMENTAL, partial); - // Incremental mode rewards partial success proportionally - expect(score).toBeGreaterThan(0); - expect(score).toBeLessThan( - calculateScore(gameModeEnum.INCREMENTAL, { - successRate: 1, - timeSpent: 100 - }) - ); - }); - }); - - describe("sortSubmissionsByGameMode", () => { - it("should sort submissions correctly for FASTEST mode", () => { - const gameStart = new Date("2024-01-01T00:00:00Z"); - const submissions = [ - { - result: { successRate: 1 }, - createdAt: new Date("2024-01-01T00:00:20Z"), - id: "slow" - }, - { - result: { successRate: 1 }, - createdAt: new Date("2024-01-01T00:00:10Z"), - id: "fast" - }, - { - result: { successRate: 0.5 }, - createdAt: new Date("2024-01-01T00:00:05Z"), - id: "failed" - } - ]; - - const sorted = sortSubmissionsByGameMode( - submissions, - gameModeEnum.FASTEST, - gameStart - ); - - expect(sorted[0].id).toBe("fast"); - expect(sorted[1].id).toBe("slow"); - expect(sorted[2].id).toBe("failed"); - }); - - it("should sort submissions correctly for SHORTEST mode", () => { - const gameStart = new Date("2024-01-01T00:00:00Z"); - const submissions = [ - { - result: { successRate: 1 }, - createdAt: new Date("2024-01-01T00:00:10Z"), - codeLength: 100, - id: "long" - }, - { - result: { successRate: 1 }, - createdAt: new Date("2024-01-01T00:00:10Z"), - codeLength: 50, - id: "short" - } - ]; - - const sorted = sortSubmissionsByGameMode( - submissions, - gameModeEnum.SHORTEST, - gameStart - ); - - expect(sorted[0].id).toBe("short"); - expect(sorted[1].id).toBe("long"); - }); - - it("should prioritize success rate above all other factors", () => { - const gameStart = new Date("2024-01-01T00:00:00Z"); - const submissions = [ - { - result: { successRate: 0.8 }, - createdAt: new Date("2024-01-01T00:00:05Z"), - id: "partial" - }, - { - result: { successRate: 1 }, - createdAt: new Date("2024-01-01T00:01:00Z"), - id: "complete" - } - ]; - - const sorted = sortSubmissionsByGameMode( - submissions, - gameModeEnum.FASTEST, - gameStart - ); - - expect(sorted[0].id).toBe("complete"); - expect(sorted[1].id).toBe("partial"); - }); - }); - - describe("getGameModeConfig", () => { - it("should return config for all defined game modes", () => { - const modes = Object.values(gameModeEnum); - - modes.forEach((mode) => { - const config = getGameModeConfig(mode); - expect(config).toBeDefined(); - expect(config.calculateScore).toBeTypeOf("function"); - expect(config.compareSubmissions).toBeTypeOf("function"); - expect(Array.isArray(config.displayMetrics)).toBe(true); - }); - }); - - it("should have correct display metrics for each mode", () => { - expect(getGameModeConfig(gameModeEnum.FASTEST).displayMetrics).toContain( - "time" - ); - expect(getGameModeConfig(gameModeEnum.SHORTEST).displayMetrics).toContain( - "length" - ); - expect( - getGameModeConfig(gameModeEnum.TYPERACER).displayMetrics - ).toContain("speed"); - expect(getGameModeConfig(gameModeEnum.DEBUG).displayMetrics).toContain( - "changes" - ); - }); - }); -}); diff --git a/libs/backend/src/tests/health.test.ts b/libs/backend/src/tests/health.test.ts deleted file mode 100644 index eb54fa4e..00000000 --- a/libs/backend/src/tests/health.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, beforeEach, afterEach, it, expect } from "vitest"; -import fastify, { FastifyInstance } from "fastify"; -import { backendUrls, httpRequestMethod, httpResponseCodes } from "types"; -import router from "@/router.js"; -import { healthResponse } from "@/routes/health/index.js"; - -describe("Health Check Endpoint", () => { - let app: FastifyInstance; - - beforeEach(async () => { - app = fastify(); - await app.register(router); - await app.ready(); - }); - - afterEach(async () => { - await app.close(); - }); - - it(`should return ${httpResponseCodes.SUCCESSFUL.OK} and status ${healthResponse}`, async () => { - const response = await app.inject({ - method: httpRequestMethod.GET, - url: backendUrls.HEALTH - }); - - expect(response.statusCode).toBe(httpResponseCodes.SUCCESSFUL.OK); - expect(response.json()).toEqual({ status: healthResponse }); - }); -}); diff --git a/libs/backend/src/types/fastify.d.ts b/libs/backend/src/types/fastify.d.ts deleted file mode 100644 index 9a85e4e7..00000000 --- a/libs/backend/src/types/fastify.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import "fastify"; -import { ErrorResponse, PistonRuntime } from "types"; - -declare module "fastify" { - interface FastifyInstance { - authenticate(request: FastifyRequest, reply: FastifyReply): Promise; - piston( - pistonExecutionRequestObject: PistonExecutionRequest - ): Promise; - runtimes(): Promise; - } - interface FastifyRequest { - user?: { userId: string; username: string }; // Extend the request type to include user - } -} diff --git a/libs/backend/src/types/jwt.d.ts b/libs/backend/src/types/jwt.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/backend/src/types/types.d.ts b/libs/backend/src/types/types.d.ts deleted file mode 100644 index 58a783b1..00000000 --- a/libs/backend/src/types/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type ParamsId = { Params: { id: string } }; diff --git a/libs/backend/src/utils/constants/model.ts b/libs/backend/src/utils/constants/model.ts deleted file mode 100644 index 8d891322..00000000 --- a/libs/backend/src/utils/constants/model.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const USER = "User"; -export const PUZZLE = "Puzzle"; -export const SUBMISSION = "Submission"; -export const METRICS = "Metrics"; -export const USER_METRICS = "UserMetrics"; -export const GAME = "Game"; -export const PREFERENCES = "Preferences"; -export const COMMENT = "Comment"; -export const USER_VOTE = "UserVote"; -export const REPORT = "Report"; -export const CHAT_MESSAGE = "ChatMessage"; -export const USER_BAN = "UserBan"; -export const PROGRAMMING_LANGUAGE = "ProgrammingLanguage"; diff --git a/libs/backend/src/utils/functions/build-piston-uri.ts b/libs/backend/src/utils/functions/build-piston-uri.ts deleted file mode 100644 index 591a9f8b..00000000 --- a/libs/backend/src/utils/functions/build-piston-uri.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ERROR_MESSAGES } from "types"; - -export function buildPistonUri(url: string) { - const pistonUrl = process.env.PISTON_URI; - - if (!pistonUrl) { - throw new Error( - `${ERROR_MESSAGES.SERVER.INTERNAL_ERROR}: PISTON_URI environment variable is not set` - ); - } - - return `${pistonUrl}${url}`; -} diff --git a/libs/backend/src/utils/functions/calculate-result.ts b/libs/backend/src/utils/functions/calculate-result.ts deleted file mode 100644 index 4a263ed5..00000000 --- a/libs/backend/src/utils/functions/calculate-result.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - isPistonExecutionResponseSuccess, - PistonExecutionResponse, - PuzzleResultEnum, - PuzzleResultInformation -} from "types"; - -function compareOutputWithExpectedOutput( - expectedOutput: string, - output: string -) { - return expectedOutput.trimEnd() === output.trimEnd(); -} - -export function calculateResults( - expectedOutput: string[], - executionResponses: PistonExecutionResponse[] -): PuzzleResultInformation & { passed: number; failed: number; total: number } { - const successfulTests = executionResponses.reduce( - (previous, executionResponse, index) => { - if (isPistonExecutionResponseSuccess(executionResponse)) { - const currentExpectedOutput = expectedOutput[index]; - - return ( - previous + - Number( - compareOutputWithExpectedOutput( - currentExpectedOutput, - executionResponse.run.output - ) || - compareOutputWithExpectedOutput( - currentExpectedOutput, - executionResponse.run.stdout - ) - ) - ); - } - - return previous; - }, - 0 - ); - - const totalTests = executionResponses.length; - const successRate = successfulTests / totalTests; - const failedTests = totalTests - successfulTests; - - return { - result: - successfulTests === totalTests - ? PuzzleResultEnum.SUCCESS - : PuzzleResultEnum.ERROR, - successRate, - passed: successfulTests, - failed: failedTests, - total: totalTests - }; -} diff --git a/libs/backend/src/utils/functions/check-all-validators.ts b/libs/backend/src/utils/functions/check-all-validators.ts deleted file mode 100644 index 09dbb880..00000000 --- a/libs/backend/src/utils/functions/check-all-validators.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { - arePistonRuntimes, - isPistonExecutionResponseSuccess, - PistonExecutionRequest, - PuzzleDto, - isProgrammingLanguageDto -} from "types"; -import { findRuntime } from "./findRuntimeInfo.js"; -import { calculateResults } from "./calculate-result.js"; - -export async function checkAllValidators( - puzzle: PuzzleDto, - fastify: FastifyInstance -): Promise { - const runtimes = await fastify.runtimes(); - - if (!arePistonRuntimes(runtimes)) { - fastify.log.error("Piston runtimes unavailable"); - return false; - } - - if (!puzzle.solution) { - return false; - } - - // Get the language name from the programming language (could be string ObjectId or populated object) - const languageName = isProgrammingLanguageDto( - puzzle.solution.programmingLanguage - ) - ? puzzle.solution.programmingLanguage.language - : undefined; - - if (!languageName) { - return false; - } - - const runtimeInfo = findRuntime(runtimes, languageName); - - if (!runtimeInfo) { - return false; - } - - if (!puzzle.validators || puzzle.validators.length === 0) { - return false; - } - - for (const validator of puzzle.validators) { - const requestObject: PistonExecutionRequest = { - language: runtimeInfo.language, - version: runtimeInfo.version, - files: [{ content: puzzle.solution.code }], - stdin: validator.input - }; - - try { - const executionRes = await fastify.piston(requestObject); - - if (!isPistonExecutionResponseSuccess(executionRes)) { - return false; - } - - const item = calculateResults([validator.output], [executionRes]); - - if (item.successRate !== 1) { - return false; - } - } catch (error) { - fastify.log.error(error, `Validator ${validator} execution failed`); - return false; - } - } - - return true; -} diff --git a/libs/backend/src/utils/functions/findRuntimeInfo.ts b/libs/backend/src/utils/functions/findRuntimeInfo.ts deleted file mode 100644 index 4c476307..00000000 --- a/libs/backend/src/utils/functions/findRuntimeInfo.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PistonRuntime } from "types"; - -export function findRuntime(runtimes: PistonRuntime[], language: string) { - return runtimes.find((runtime) => runtime.language === language); -} diff --git a/libs/backend/src/utils/functions/generate-token.ts b/libs/backend/src/utils/functions/generate-token.ts deleted file mode 100644 index bb315336..00000000 --- a/libs/backend/src/utils/functions/generate-token.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FastifyInstance } from "fastify"; -import { AuthenticatedInfo } from "types"; - -export function generateToken( - fastify: FastifyInstance, - payload: AuthenticatedInfo -): string { - try { - return fastify.jwt.sign(payload, { expiresIn: "24h" }); - } catch (error) { - console.error("Error generating token:", error); - throw new Error("Token generation failed"); - } -} diff --git a/libs/backend/src/utils/functions/is-validation-error.ts b/libs/backend/src/utils/functions/is-validation-error.ts deleted file mode 100644 index 9c7fee81..00000000 --- a/libs/backend/src/utils/functions/is-validation-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Error } from "mongoose"; - -export function isValidationError( - error: unknown -): error is Error.ValidationError { - return error instanceof Error.ValidationError; -} diff --git a/libs/backend/src/utils/functions/parse-raw-data-message.ts b/libs/backend/src/utils/functions/parse-raw-data-message.ts deleted file mode 100644 index a8896340..00000000 --- a/libs/backend/src/utils/functions/parse-raw-data-message.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - GameRequest, - isGameRequest, - isWaitingRoomRequest, - WaitingRoomRequest -} from "types"; -import { RawData } from "ws"; - -function convertRawDataToString(message: RawData): string { - if (Buffer.isBuffer(message)) { - return message.toString("utf-8"); - } else if (Array.isArray(message)) { - return message.map((buf) => buf.toString("utf-8")).join(""); - } else if (message instanceof ArrayBuffer) { - return Buffer.from(message).toString("utf-8"); - } else { - throw new Error("unable to convert, unsupported raw message type"); - } -} - -export function parseRawDataWaitingRoomRequest( - message: RawData -): WaitingRoomRequest { - const messageString = convertRawDataToString(message); - - const receivedMessageData = JSON.parse(messageString); - - if (!isWaitingRoomRequest(receivedMessageData)) { - throw new Error("parsing message failed"); - } - - return receivedMessageData; -} - -export function parseRawDataGameRequest(message: RawData): GameRequest { - const messageString = convertRawDataToString(message); - - const receivedMessageData = JSON.parse(messageString); - - if (!isGameRequest(receivedMessageData)) { - throw new Error("parsing message failed"); - } - - return receivedMessageData; -} diff --git a/libs/backend/src/utils/game-mode/README.md b/libs/backend/src/utils/game-mode/README.md deleted file mode 100644 index d4eb22bd..00000000 --- a/libs/backend/src/utils/game-mode/README.md +++ /dev/null @@ -1,110 +0,0 @@ -# Game Mode Architecture - -## Overview - -CodinCod uses a **strategy pattern** to handle different game modes. This makes it easy to add new game modes without modifying existing code. - -## Current Game Modes - -- **FASTEST**: Solve the puzzle as quickly as possible (default for competitive play) -- **SHORTEST**: Solve the puzzle with the least amount of characters (code golf) -- **RATED**: Competitive mode with ELO-style ranking (affects player ratings) -- **CASUAL**: Non-competitive mode (doesn't affect ratings) - -## How to Add a New Game Mode - -### 1. Add the Mode to the Enum - -Update `libs/types/src/core/game/enum/game-mode-enum.ts`: - -```typescript -export const gameModeEnum = { - FASTEST: "fastest", - SHORTEST: "shortest", - RATED: "rated", - CASUAL: "casual", - YOUR_NEW_MODE: "your_new_mode" // Add your mode here -} as const; -``` - -### 2. Create a Strategy Class - -In `libs/backend/src/utils/game-mode/game-mode-strategy.ts`, create a new strategy: - -```typescript -class YourNewModeStrategy implements GameModeStrategy { - calculateScore(submission: { - successRate: number; - timeSpent: number; - codeLength?: number; - // Add any custom metrics you need - }): number { - // Return a numeric score for the submission - // Higher is better - return 0; - } - - compareSubmissions( - a: { successRate: number; timeSpent: number; codeLength?: number }, - b: { successRate: number; timeSpent: number; codeLength?: number } - ): number { - // Return negative if a is better, positive if b is better, 0 if equal - // This determines leaderboard order - return 0; - } - - getDisplayMetrics(): string[] { - // Return which metrics should be shown in the UI - return ["score", "yourMetric"]; - } -} -``` - -### 3. Register the Strategy - -Add your strategy to the `strategies` object: - -```typescript -const strategies: Record = { - // ... existing strategies - [gameModeEnum.YOUR_NEW_MODE]: new YourNewModeStrategy() -}; -``` - -### 4. Add Required Data Fields - -If your mode needs new submission data (like `codeLength` for SHORTEST mode): - -1. Update `libs/types/src/core/submission/schema/submission-entity.schema.ts` -2. Update `libs/backend/src/models/submission/submission.ts` -3. Update submission routes to calculate/store the data - -### 5. Update the Frontend - -Update `libs/frontend/src/lib/features/game/standings/components/standings-table.svelte`: - -```svelte -{#if game.options.mode === gameModeEnum.YOUR_NEW_MODE} - Your Metric -{/if} -``` - -## Architecture Benefits - -✅ **Extensible**: Add new modes without changing existing code -✅ **Type-safe**: TypeScript ensures all modes are handled -✅ **Testable**: Each strategy can be unit tested independently -✅ **Maintainable**: Mode-specific logic is isolated - -## Example: Adding a "Memory Efficient" Mode - -1. Add `MEMORY_EFFICIENT: "memory_efficient"` to gameModeEnum -2. Create `MemoryEfficientModeStrategy` that: - - Tracks peak memory usage during execution - - Scores based on lowest memory usage + success rate - - Breaks ties by execution time -3. Add `peakMemoryUsage` field to SubmissionEntity -4. Update Piston execution to capture memory metrics -5. Update standings table to show memory usage column - -That's it! The game mode system handles the rest automatically. diff --git a/libs/backend/src/utils/game-mode/game-mode-strategy.ts b/libs/backend/src/utils/game-mode/game-mode-strategy.ts deleted file mode 100644 index 6b9a975b..00000000 --- a/libs/backend/src/utils/game-mode/game-mode-strategy.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { gameModeEnum, type GameMode } from "types"; - -/** - * Submission data for scoring and comparison - */ -export interface SubmissionData { - successRate: number; - timeSpent: number; - codeLength?: number; - attempts?: number | undefined; -} - -/** - * Game mode configuration - */ -export interface GameModeConfig { - displayMetrics: string[]; - calculateScore: (submission: SubmissionData) => number; - compareSubmissions: (a: SubmissionData, b: SubmissionData) => number; -} - -/** - * Calculate score for FASTEST mode - * Score is inversely proportional to time spent - */ -function calculateFastestScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - return 1000000 / submission.timeSpent; -} - -/** - * Compare submissions for FASTEST mode - * Priority: success rate > time - */ -function compareFastestSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for SHORTEST mode - * Score is inversely proportional to code length - */ -function calculateShortestScore(submission: SubmissionData): number { - if (submission.successRate < 1 || !submission.codeLength) return 0; - return 1000000 / submission.codeLength; -} - -/** - * Compare submissions for SHORTEST mode - * Priority: success rate > code length > time - */ -function compareShortestSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - const aLength = a.codeLength ?? Number.MAX_SAFE_INTEGER; - const bLength = b.codeLength ?? Number.MAX_SAFE_INTEGER; - if (aLength !== bLength) { - return aLength - bLength; - } - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for BACKWARDS mode - * Solve from output to input - scoring based on logical steps - * Same as FASTEST but with bonus for fewer attempts - */ -function calculateBackwardsScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - const baseScore = 1000000 / submission.timeSpent; - const attemptPenalty = (submission.attempts ?? 1) * 0.1; - return baseScore * (1 - attemptPenalty); -} - -/** - * Compare submissions for BACKWARDS mode - * Priority: success rate > attempts > time - */ -function compareBackwardsSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - const aAttempts = a.attempts ?? 1; - const bAttempts = b.attempts ?? 1; - if (aAttempts !== bAttempts) { - return aAttempts - bAttempts; - } - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for HARDCORE mode - * One attempt only - binary score - */ -function calculateHardcoreScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - if ((submission.attempts ?? 1) > 1) return 0; // Failed - multiple attempts - return 1000000 / submission.timeSpent; // Succeeded on first try -} - -/** - * Compare submissions for HARDCORE mode - * Priority: success on first attempt > time - */ -function compareHardcoreSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - const aSuccess = a.successRate === 1 && (a.attempts ?? 1) === 1 ? 1 : 0; - const bSuccess = b.successRate === 1 && (b.attempts ?? 1) === 1 ? 1 : 0; - - if (aSuccess !== bSuccess) { - return bSuccess - aSuccess; - } - if (aSuccess === 0) return 0; // Both failed - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for DEBUG mode - * Fix broken code - bonus for fewer changes - */ -function calculateDebugScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - const baseScore = 1000000 / submission.timeSpent; - // Smaller code changes = higher score (assume original code is baseline) - const changeFactor = submission.codeLength - ? Math.max(0.5, 1 - submission.codeLength / 10000) - : 1; - return baseScore * changeFactor; -} - -/** - * Compare submissions for DEBUG mode - * Priority: success rate > fewer changes > time - */ -function compareDebugSubmissions(a: SubmissionData, b: SubmissionData): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - const aLength = a.codeLength ?? Number.MAX_SAFE_INTEGER; - const bLength = b.codeLength ?? Number.MAX_SAFE_INTEGER; - if (aLength !== bLength) { - return aLength - bLength; // Fewer changes is better - } - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for EFFICIENCY mode - * Focus on computational efficiency (simulated by code quality metrics) - */ -function calculateEfficiencyScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - // Efficiency approximated by code length and time - // Shorter, faster code = more efficient - const timeComponent = 1000000 / submission.timeSpent; - const lengthComponent = submission.codeLength - ? 500000 / submission.codeLength - : 0; - return timeComponent * 0.4 + lengthComponent * 0.6; -} - -/** - * Compare submissions for EFFICIENCY mode - * Priority: success rate > efficiency score - */ -function compareEfficiencySubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - const aScore = calculateEfficiencyScore(a); - const bScore = calculateEfficiencyScore(b); - return bScore - aScore; -} - -/** - * Calculate score for TYPERACER mode - * Copy code perfectly, fastest wins - */ -function calculateTyperacerScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - // Pure speed - character per second rate - const charsPerSecond = (submission.codeLength ?? 0) / submission.timeSpent; - return charsPerSecond * 1000; -} - -/** - * Compare submissions for TYPERACER mode - * Priority: success rate > typing speed (chars/sec) - */ -function compareTyperacerSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - const aSpeed = (a.codeLength ?? 0) / a.timeSpent; - const bSpeed = (b.codeLength ?? 0) / b.timeSpent; - return bSpeed - aSpeed; -} - -/** - * Calculate score for INCREMENTAL mode - * Requirements added each minute - handle complexity over time - */ -function calculateIncrementalScore(submission: SubmissionData): number { - // Allow partial success in incremental mode (requirements build over time) - if (submission.successRate === 0) return 0; - // Score decreases with time (earlier completion = higher score) - const timeDecayFactor = Math.max(0.1, 1 - submission.timeSpent / 3600); - return 1000000 * submission.successRate * timeDecayFactor; -} - -/** - * Compare submissions for INCREMENTAL mode - * Priority: success rate > earlier completion time - */ -function compareIncrementalSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - // Earlier completion wins - return a.timeSpent - b.timeSpent; -} - -/** - * Calculate score for LEGACY_MODE - * Must maintain backwards compatibility - fewer changes to working code - * NOTE: LEGACY_MODE has been removed from gameModeEnum but kept for backwards compatibility - */ -export function calculateLegacyScore(submission: SubmissionData): number { - if (submission.successRate < 1) return 0; - // Reward minimal changes and fast completion - const baseScore = 1000000 / submission.timeSpent; - const changeBonus = submission.codeLength - ? Math.max(0.5, 1 - submission.codeLength / 5000) - : 0.5; - return baseScore * (0.5 + changeBonus * 0.5); -} - -/** - * Default scoring and comparison for unsupported modes - */ -function calculateDefaultScore(submission: SubmissionData): number { - return submission.successRate === 1 ? 1000000 / submission.timeSpent : 0; -} - -function compareDefaultSubmissions( - a: SubmissionData, - b: SubmissionData -): number { - if (a.successRate !== b.successRate) { - return b.successRate - a.successRate; - } - return a.timeSpent - b.timeSpent; -} - -/** - * Game mode configurations using functional composition - * Each mode defines its scoring logic, comparison function, and display metrics - */ -const gameModeConfigs: Record = { - [gameModeEnum.FASTEST]: { - displayMetrics: ["score", "time"], - calculateScore: calculateFastestScore, - compareSubmissions: compareFastestSubmissions - }, - [gameModeEnum.SHORTEST]: { - displayMetrics: ["score", "length", "time"], - calculateScore: calculateShortestScore, - compareSubmissions: compareShortestSubmissions - }, - [gameModeEnum.BACKWARDS]: { - displayMetrics: ["score", "attempts", "time"], - calculateScore: calculateBackwardsScore, - compareSubmissions: compareBackwardsSubmissions - }, - [gameModeEnum.HARDCORE]: { - displayMetrics: ["score", "time", "attempts"], - calculateScore: calculateHardcoreScore, - compareSubmissions: compareHardcoreSubmissions - }, - [gameModeEnum.DEBUG]: { - displayMetrics: ["score", "changes", "time"], - calculateScore: calculateDebugScore, - compareSubmissions: compareDebugSubmissions - }, - [gameModeEnum.EFFICIENCY]: { - displayMetrics: ["score", "efficiency", "time", "length"], - calculateScore: calculateEfficiencyScore, - compareSubmissions: compareEfficiencySubmissions - }, - [gameModeEnum.TYPERACER]: { - displayMetrics: ["score", "speed", "time"], - calculateScore: calculateTyperacerScore, - compareSubmissions: compareTyperacerSubmissions - }, - [gameModeEnum.INCREMENTAL]: { - displayMetrics: ["score", "time", "completion"], - calculateScore: calculateIncrementalScore, - compareSubmissions: compareIncrementalSubmissions - }, - [gameModeEnum.RANDOM]: { - displayMetrics: ["score", "time"], - calculateScore: calculateDefaultScore, - compareSubmissions: compareDefaultSubmissions - } -}; - -/** - * Get game mode configuration for a specific mode - * Uses functional approach with switch statement for clarity - */ -export function getGameModeConfig(mode: GameMode): GameModeConfig { - switch (mode) { - case gameModeEnum.FASTEST: - case gameModeEnum.SHORTEST: - case gameModeEnum.BACKWARDS: - case gameModeEnum.HARDCORE: - case gameModeEnum.DEBUG: - case gameModeEnum.EFFICIENCY: - case gameModeEnum.TYPERACER: - case gameModeEnum.INCREMENTAL: - case gameModeEnum.RANDOM: - return gameModeConfigs[mode]; - default: - // Exhaustiveness check - const exhaustiveCheck: never = mode; - console.warn(`Unknown game mode: ${exhaustiveCheck}, using default`); - return gameModeConfigs[gameModeEnum.FASTEST]; - } -} - -/** - * Calculate score for a submission based on game mode - */ -export function calculateScore( - mode: GameMode, - submission: SubmissionData -): number { - const config = getGameModeConfig(mode); - return config.calculateScore(submission); -} - -/** - * Get display metrics for a game mode - */ -export function getDisplayMetrics(mode: GameMode): string[] { - const config = getGameModeConfig(mode); - return config.displayMetrics; -} - -/** - * Sort submissions by game mode using functional composition - */ -export function sortSubmissionsByGameMode< - T extends { - result: { successRate: number }; - createdAt: Date | string; - codeLength?: number; - attempts?: number; - } ->(submissions: T[], mode: GameMode, gameStartTime: Date | string): T[] { - const config = getGameModeConfig(mode); - const startTime = new Date(gameStartTime).getTime(); - - return [...submissions].sort((a, b) => { - const aTime = (new Date(a.createdAt).getTime() - startTime) / 1000; - const bTime = (new Date(b.createdAt).getTime() - startTime) / 1000; - - const aData: SubmissionData = { - successRate: a.result.successRate, - timeSpent: aTime, - ...(a.codeLength !== undefined && { codeLength: a.codeLength }), - ...(a.attempts !== undefined && { attempts: a.attempts }) - }; - - const bData: SubmissionData = { - successRate: b.result.successRate, - timeSpent: bTime, - ...(b.codeLength !== undefined && { codeLength: b.codeLength }), - ...(b.attempts !== undefined && { attempts: b.attempts }) - }; - - return config.compareSubmissions(aData, bData); - }); -} diff --git a/libs/backend/src/utils/moderation/escalation.ts b/libs/backend/src/utils/moderation/escalation.ts deleted file mode 100644 index b29d195b..00000000 --- a/libs/backend/src/utils/moderation/escalation.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { - getBanDuration, - shouldAutoBan, - shouldBePermanent, - banTypeEnum, - ObjectId -} from "types"; -import UserBan from "../../models/moderation/user-ban.js"; -import User from "../../models/user/user.js"; -import mongoose from "mongoose"; - -export async function applyAutomaticEscalation( - userId: ObjectId, - moderatorId: ObjectId, - reason: string -): Promise { - const user = await User.findById(userId); - - if (!user) { - throw new Error("User not found"); - } - - const reportCount = user.reportCount || 0; - - if (!shouldAutoBan(reportCount)) { - return null; - } - - const isPermanent = shouldBePermanent(reportCount); - const durationMs = getBanDuration(reportCount); - - const banData = { - userId: new mongoose.Types.ObjectId(userId), - bannedBy: new mongoose.Types.ObjectId(moderatorId), - banType: isPermanent ? banTypeEnum.PERMANENT : banTypeEnum.TEMPORARY, - reason: `Automatic escalation: ${reason}`, - startDate: new Date(), - endDate: durationMs ? new Date(Date.now() + durationMs) : undefined, - isActive: true - }; - - const ban = new UserBan(banData); - const savedBan = await ban.save(); - - user.banCount = (user.banCount || 0) + 1; - user.currentBan = savedBan._id as mongoose.Types.ObjectId; - await user.save(); - - return savedBan; -} - -export async function checkUserBanStatus( - userId: ObjectId -): Promise<{ isBanned: boolean; ban?: typeof UserBan.prototype }> { - const user = await User.findById(userId).populate("currentBan"); - - if (!user || !user.currentBan) { - return { isBanned: false }; - } - - const ban = await UserBan.findById(user.currentBan); - - if (!ban || !ban.isActive) { - user.currentBan = null; - await user.save(); - return { isBanned: false }; - } - - if (ban.banType === banTypeEnum.TEMPORARY && ban.endDate) { - if (new Date() > ban.endDate) { - ban.isActive = false; - await ban.save(); - user.currentBan = null; - await user.save(); - return { isBanned: false }; - } - } - - return { isBanned: true, ban }; -} - -/** - * Manually unban a user - */ -export async function unbanUser( - userId: ObjectId, - moderatorId: ObjectId, - reason: string -): Promise { - const user = await User.findById(userId); - - if (!user || !user.currentBan) { - throw new Error("User is not currently banned"); - } - - const ban = await UserBan.findById(user.currentBan); - - if (ban) { - ban.isActive = false; - ban.reason = `${ban.reason} | Unbanned by moderator: ${reason}`; - await ban.save(); - } - - user.currentBan = null; - await user.save(); -} - -export async function createTemporaryBan( - userId: ObjectId, - moderatorId: ObjectId, - reason: string, - durationMs: number -): Promise { - const user = await User.findById(userId); - - if (!user) { - throw new Error("User not found"); - } - - // Deactivate any existing ban - if (user.currentBan) { - const existingBan = await UserBan.findById(user.currentBan); - if (existingBan) { - existingBan.isActive = false; - await existingBan.save(); - } - } - - const ban = new UserBan({ - userId: new mongoose.Types.ObjectId(userId), - bannedBy: new mongoose.Types.ObjectId(moderatorId), - banType: banTypeEnum.TEMPORARY, - reason, - startDate: new Date(), - endDate: new Date(Date.now() + durationMs), - isActive: true - }); - - const savedBan = await ban.save(); - - user.banCount = (user.banCount || 0) + 1; - user.currentBan = savedBan._id as mongoose.Types.ObjectId; - await user.save(); - - return savedBan; -} - -export async function createPermanentBan( - userId: ObjectId, - moderatorId: ObjectId, - reason: string -): Promise { - const user = await User.findById(userId); - - if (!user) { - throw new Error("User not found"); - } - - // Deactivate any existing ban - if (user.currentBan) { - const existingBan = await UserBan.findById(user.currentBan); - if (existingBan) { - existingBan.isActive = false; - await existingBan.save(); - } - } - - const ban = new UserBan({ - userId: new mongoose.Types.ObjectId(userId), - bannedBy: new mongoose.Types.ObjectId(moderatorId), - banType: banTypeEnum.PERMANENT, - reason, - startDate: new Date(), - isActive: true - }); - - const savedBan = await ban.save(); - - user.banCount = (user.banCount || 0) + 1; - user.currentBan = savedBan._id as mongoose.Types.ObjectId; - await user.save(); - - return savedBan; -} - -export async function incrementReportCount(userId: ObjectId): Promise { - const user = await User.findById(userId); - - if (!user) { - throw new Error("User not found"); - } - - user.reportCount = (user.reportCount || 0) + 1; - await user.save(); - - return user.reportCount; -} diff --git a/libs/backend/src/utils/rating/glicko.ts b/libs/backend/src/utils/rating/glicko.ts deleted file mode 100644 index 11d0a6fe..00000000 --- a/libs/backend/src/utils/rating/glicko.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Glicko-2 Rating System Implementation - * - * A simplified, rudimentary implementation of the Glicko-2 rating system - * for competitive multiplayer games. This can be expanded later with more - * sophisticated calculations. - * - * Reference: http://www.glicko.net/glicko/glicko2.pdf - */ - -export interface GlickoRating { - rating: number; // Player's rating (μ) - rd: number; // Rating deviation (φ) - uncertainty in rating - volatility: number; // Volatility (σ) - degree of expected fluctuation - lastUpdated: Date; -} - -export interface GameOutcome { - opponentRating: number; - opponentRd: number; - score: number; // 1 = win, 0.5 = draw, 0 = loss -} - -// Glicko-2 system constants -const TAU = 0.5; // System constant (volatility constraint) -const EPSILON = 0.000001; // Convergence tolerance -const GLICKO_SCALE = 173.7178; // Conversion factor (q in original Glicko) - -/** - * Convert Glicko-2 rating to Glicko-2 scale - */ -function toGlicko2Scale(rating: number): number { - return (rating - 1500) / GLICKO_SCALE; -} - -/** - * Convert Glicko-2 scale back to rating - */ -function fromGlicko2Scale(mu: number): number { - return mu * GLICKO_SCALE + 1500; -} - -/** - * g function - measures impact of opponent's RD - */ -function g(rd: number): number { - const phi = rd / GLICKO_SCALE; - return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI)); -} - -/** - * E function - expected score against opponent - */ -function E(playerMu: number, opponentMu: number, opponentPhi: number): number { - const gValue = g(opponentPhi * GLICKO_SCALE); - return 1 / (1 + Math.exp(-gValue * (playerMu - opponentMu))); -} - -/** - * Calculate variance (v) based on opponents - */ -function calculateVariance(playerMu: number, outcomes: GameOutcome[]): number { - let sum = 0; - for (const outcome of outcomes) { - const opponentMu = toGlicko2Scale(outcome.opponentRating); - const opponentPhi = outcome.opponentRd / GLICKO_SCALE; - const gValue = g(outcome.opponentRd); - const eValue = E(playerMu, opponentMu, opponentPhi); - sum += gValue * gValue * eValue * (1 - eValue); - } - return sum > 0 ? 1 / sum : Infinity; -} - -/** - * Calculate delta (improvement in rating) - */ -function calculateDelta(playerMu: number, outcomes: GameOutcome[]): number { - let sum = 0; - for (const outcome of outcomes) { - const opponentMu = toGlicko2Scale(outcome.opponentRating); - const opponentPhi = outcome.opponentRd / GLICKO_SCALE; - const gValue = g(outcome.opponentRd); - const eValue = E(playerMu, opponentMu, opponentPhi); - sum += gValue * (outcome.score - eValue); - } - return sum; -} - -/** - * Simplified volatility update (using Newton-Raphson approximation) - * This is a rudimentary implementation - can be refined later - */ -function updateVolatility( - sigma: number, - phi: number, - v: number, - delta: number -): number { - const a = Math.log(sigma * sigma); - const delta2 = delta * delta; - const phi2 = phi * phi; - - // Simplified calculation - good enough for initial implementation - const f = (x: number): number => { - const ex = Math.exp(x); - return ( - (ex * (delta2 - phi2 - v - ex)) / - (2 * (phi2 + v + ex) * (phi2 + v + ex)) - - (x - a) / (TAU * TAU) - ); - }; - - // Find approximate solution - let A = a; - let B: number; - if (delta2 > phi2 + v) { - B = Math.log(delta2 - phi2 - v); - } else { - let k = 1; - while (f(a - k * TAU) < 0) { - k++; - } - B = a - k * TAU; - } - - // Newton-Raphson iterations (simplified) - let fA = f(A); - let fB = f(B); - - while (Math.abs(B - A) > EPSILON) { - const C = A + ((A - B) * fA) / (fB - fA); - const fC = f(C); - - if (fC * fB < 0) { - A = B; - fA = fB; - } else { - fA = fA / 2; - } - - B = C; - fB = fC; - } - - return Math.exp(A / 2); -} - -/** - * Calculate new rating after a series of games - * This is the main function to use for rating updates - */ -export function calculateNewRating( - currentRating: GlickoRating, - outcomes: GameOutcome[] -): GlickoRating { - // If no games, increase RD (rating becomes more uncertain over time) - if (outcomes.length === 0) { - const daysSinceLastGame = - (Date.now() - currentRating.lastUpdated.getTime()) / - (1000 * 60 * 60 * 24); - const rdIncrease = Math.min( - Math.sqrt(currentRating.rd * currentRating.rd + daysSinceLastGame * 2), - 350 - ); - - return { - ...currentRating, - rd: rdIncrease, - lastUpdated: new Date() - }; - } - - // Convert to Glicko-2 scale - const mu = toGlicko2Scale(currentRating.rating); - const phi = currentRating.rd / GLICKO_SCALE; - const sigma = currentRating.volatility; - - // Step 3: Calculate v (variance) - const v = calculateVariance(mu, outcomes); - - // Step 4: Calculate delta (improvement) - const delta = v * calculateDelta(mu, outcomes); - - // Step 5: Update volatility - const newSigma = updateVolatility(sigma, phi, v, delta); - - // Step 6: Update phi (rating deviation) - const phiStar = Math.sqrt(phi * phi + newSigma * newSigma); - - // Step 7: Update phi and mu - const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v); - const newMu = mu + newPhi * newPhi * calculateDelta(mu, outcomes); - - // Convert back to Glicko scale - return { - rating: fromGlicko2Scale(newMu), - rd: newPhi * GLICKO_SCALE, - volatility: newSigma, - lastUpdated: new Date() - }; -} - -/** - * Initialize default rating for new player - */ -export function getDefaultRating(): GlickoRating { - return { - rating: 1500, - rd: 350, - volatility: 0.06, - lastUpdated: new Date() - }; -} - -/** - * Calculate expected win probability against opponent - */ -export function expectedWinProbability( - playerRating: GlickoRating, - opponentRating: GlickoRating -): number { - const playerMu = toGlicko2Scale(playerRating.rating); - const opponentMu = toGlicko2Scale(opponentRating.rating); - const opponentPhi = opponentRating.rd / GLICKO_SCALE; - - return E(playerMu, opponentMu, opponentPhi); -} - -/** - * Simplified rating update for head-to-head games - * Easier to use for simple win/loss scenarios - */ -export function updateRatingAfterGame( - playerRating: GlickoRating, - opponentRating: GlickoRating, - playerWon: boolean -): GlickoRating { - const outcome: GameOutcome = { - opponentRating: opponentRating.rating, - opponentRd: opponentRating.rd, - score: playerWon ? 1 : 0 - }; - - return calculateNewRating(playerRating, [outcome]); -} - -/** - * Batch update for multiple games in a rating period - */ -export function updateRatingAfterMultipleGames( - playerRating: GlickoRating, - games: Array<{ opponentRating: GlickoRating; playerWon: boolean }> -): GlickoRating { - const outcomes: GameOutcome[] = games.map((game) => ({ - opponentRating: game.opponentRating.rating, - opponentRd: game.opponentRating.rd, - score: game.playerWon ? 1 : 0 - })); - - return calculateNewRating(playerRating, outcomes); -} diff --git a/libs/backend/src/websocket/connection-manager.ts b/libs/backend/src/websocket/connection-manager.ts deleted file mode 100644 index 955497de..00000000 --- a/libs/backend/src/websocket/connection-manager.ts +++ /dev/null @@ -1,203 +0,0 @@ -import websocket from "@fastify/websocket"; -import { AuthenticatedInfo, websocketCloseCodes } from "types"; - -const websocketState = { - CONNECTING: 0, - OPEN: 1, - CLOSING: 2, - CLOSED: 3 -} as const; - -type Username = string; -type ConnectionId = string; - -interface Connection { - socket: websocket.WebSocket; - connectionId: ConnectionId; - userId: string; - heartbeatInterval?: NodeJS.Timeout; - lastPong: number; - pongHandler: () => void; -} - -interface ConnectionCallbacks { - onConnectionLost?: (username: Username) => void; - onConnectionRestored?: (username: Username) => void; -} - -export class ConnectionManager { - private connections = new Map(); - private readonly HEARTBEAT_INTERVAL = 30 * 1000; - private readonly HEARTBEAT_TIMEOUT = 35 * 1000; - private globalHeartbeatTimer?: NodeJS.Timeout; - private callbacks: ConnectionCallbacks; - - constructor(callbacks: ConnectionCallbacks = {}) { - this.callbacks = callbacks; - this.startGlobalHeartbeat(); - } - - private startGlobalHeartbeat() { - this.globalHeartbeatTimer = setInterval(() => { - this.heartbeatAll(); - }, this.HEARTBEAT_INTERVAL); - } - - private heartbeatAll() { - const now = Date.now(); - const toRemove: Username[] = []; - - for (const [username, connection] of this.connections.entries()) { - const timeSinceLastPong = now - connection.lastPong; - - if (timeSinceLastPong > this.HEARTBEAT_TIMEOUT) { - console.warn(`Heartbeat timeout for ${username}`); - toRemove.push(username); - continue; - } - - if (connection.socket.readyState === websocketState.OPEN) { - try { - connection.socket.ping(); - } catch (error) { - console.error(`Failed to ping ${username}:`, error); - toRemove.push(username); - } - } else { - toRemove.push(username); - } - } - - toRemove.forEach((username) => { - this.callbacks.onConnectionLost?.(username); - this.remove(username); - }); - - if (toRemove.length > 0) { - console.info(`Removed ${toRemove.length} dead connections`); - } - } - - add(user: AuthenticatedInfo, socket: websocket.WebSocket): ConnectionId { - const connectionId = crypto.randomUUID(); - - const existing = this.connections.get(user.username); - if (existing) { - socket.removeListener("pong", existing.pongHandler); - if (existing.socket.readyState === websocketState.OPEN) { - existing.socket.close(); - } - } - - const pongHandler = () => { - const conn = this.connections.get(user.username); - if (conn) { - conn.lastPong = Date.now(); - } - }; - - socket.on("pong", pongHandler); - - const connection: Connection = { - socket, - connectionId, - userId: user.userId, - lastPong: Date.now(), - pongHandler - }; - - this.connections.set(user.username, connection); - console.info( - `Connection established for ${user.username} (${connectionId})` - ); - - return connectionId; - } - - remove(username: Username): void { - const connection = this.connections.get(username); - if (!connection) return; - - connection.socket.removeListener("pong", connection.pongHandler); - - if (connection.socket.readyState === websocketState.OPEN) { - try { - connection.socket.close(); - } catch (error) { - console.error(`Error closing socket for ${username}:`, error); - } - } - - this.connections.delete(username); - console.info(`Connection removed for ${username}`); - } - - get(username: Username): Connection | undefined { - return this.connections.get(username); - } - - send(username: Username, data: any): boolean { - const connection = this.connections.get(username); - if (!connection || connection.socket.readyState !== websocketState.OPEN) { - return false; - } - - try { - connection.socket.send(JSON.stringify(data)); - return true; - } catch (error) { - console.error(`Failed to send message to ${username}:`, error); - this.remove(username); - return false; - } - } - - broadcast(data: any, excludeUsers: Username[] = []): void { - const message = JSON.stringify(data); - this.connections.forEach((connection, username) => { - if (excludeUsers.includes(username)) return; - - if (connection.socket.readyState === websocketState.OPEN) { - try { - connection.socket.send(message); - } catch (error) { - console.error(`Failed to broadcast to ${username}:`, error); - this.remove(username); - } - } - }); - } - - isConnected(username: Username): boolean { - const connection = this.connections.get(username); - return connection?.socket.readyState === websocketState.OPEN; - } - - getConnectionCount(): number { - return this.connections.size; - } - - getAllUsernames(): Username[] { - return Array.from(this.connections.keys()); - } - - destroy(): void { - if (this.globalHeartbeatTimer) { - clearInterval(this.globalHeartbeatTimer); - } - - for (const [_username, connection] of this.connections.entries()) { - connection.socket.removeListener("pong", connection.pongHandler); - - if (connection.socket.readyState === websocketState.OPEN) { - connection.socket.close( - websocketCloseCodes.GOING_AWAY, - "Server shutting down" - ); - } - } - - this.connections.clear(); - console.info("ConnectionManager destroyed"); - } -} diff --git a/libs/backend/src/websocket/game/game-setup.ts b/libs/backend/src/websocket/game/game-setup.ts deleted file mode 100644 index a90e4bba..00000000 --- a/libs/backend/src/websocket/game/game-setup.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { WebSocket } from "@fastify/websocket"; -import { FastifyInstance, FastifyRequest } from "fastify"; -import { onConnection } from "./on-connection.js"; -import { - ChatMessage, - gameEventEnum, - getUserIdFromUser, - isAuthenticatedInfo, - isGameDto, - isPuzzleDto, - ObjectId, - banTypeEnum, - websocketCloseCodes, - ERROR_MESSAGES -} from "types"; -import { isValidObjectId } from "mongoose"; -import { parseRawDataGameRequest } from "@/utils/functions/parse-raw-data-message.js"; -import Game, { GameDocument } from "@/models/game/game.js"; -import { UserWebSockets } from "./user-web-sockets.js"; -import { ParamsId } from "@/types/types.js"; -import Puzzle from "@/models/puzzle/puzzle.js"; -import ChatMessageModel from "@/models/chat/chat-message.js"; -import { checkUserBanStatus } from "@/utils/moderation/escalation.js"; - -const userWebSockets = new UserWebSockets(); - -function isPlayerInGame(game: GameDocument, userId: ObjectId): boolean { - return game.players.some((player) => getUserIdFromUser(player) === userId); -} - -function sendErrorAndClose(socket: WebSocket, message: string): void { - socket.send( - JSON.stringify({ - event: gameEventEnum.ERROR, - message - }) - ); - socket.close(websocketCloseCodes.POLICY_VIOLATION, message); -} - -export async function gameSetup( - socket: WebSocket, - req: FastifyRequest, - fastify: FastifyInstance -) { - const { id } = req.params; - - if (!isAuthenticatedInfo(req.user)) { - sendErrorAndClose( - socket, - ERROR_MESSAGES.AUTHENTICATION.AUTHENTICATION_REQUIRED - ); - return; - } - - // Check if user is banned - const banStatus = await checkUserBanStatus(req.user.userId); - if (banStatus.isBanned && banStatus.ban) { - sendErrorAndClose( - socket, - `You are banned: ${banStatus.ban.reason}. ${banStatus.ban.banType === banTypeEnum.PERMANENT ? "This ban is permanent." : `Ban expires: ${banStatus.ban.endDate}`}` - ); - return; - } - - if (!isValidObjectId(id)) { - sendErrorAndClose(socket, ERROR_MESSAGES.GAME.NOT_FOUND); - return; - } - - onConnection(userWebSockets, req.user, id, socket); - - // Handle ping from client - socket.on("ping", () => { - socket.pong(); - }); - - socket.on("message", async (message) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - - let parsedMessage; - - try { - parsedMessage = parseRawDataGameRequest(message); - } catch (e) { - const error = e as Error; - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.ERROR, - message: error.message - }); - return; - } - - const { event } = parsedMessage; - - switch (event) { - case gameEventEnum.JOIN_GAME: { - try { - const gameToUpdate = await Game.findById(id); - - if (!isGameDto(gameToUpdate)) { - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.NONEXISTENT_GAME, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - return; - } - - if (!isPlayerInGame(gameToUpdate, req.user.userId)) { - gameToUpdate.players.push(req.user.userId); - await gameToUpdate.save(); - } - - const game = await Game.findById(id) - .populate("owner") - .populate("players") - .populate({ - path: "playerSubmissions", - populate: { path: "user" } - }) - .exec(); - - if (!isGameDto(game)) { - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.NONEXISTENT_GAME, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - return; - } - - console.log({ game }); - - const puzzle = await Puzzle.findById(game.puzzle).populate("author"); - - if (!isPuzzleDto(puzzle)) { - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.ERROR, - message: ERROR_MESSAGES.PUZZLE.NOT_FOUND - }); - return; - } - - userWebSockets.updateAllUsers({ - event: gameEventEnum.OVERVIEW_GAME, - game, - puzzle - }); - } catch (error) { - fastify.log.error({ err: error }, "Error in JOIN_GAME"); - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.ERROR, - message: "Failed to join game" - }); - } - break; - } - - case gameEventEnum.SUBMITTED_PLAYER: { - try { - const game = await Game.findById(id) - .populate("owner") - .populate("players") - .populate({ - path: "playerSubmissions", - populate: { path: "user" } - }) - .exec(); - - if (!isGameDto(game)) { - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.NONEXISTENT_GAME, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - return; - } - - userWebSockets.updateAllUsers({ - event: gameEventEnum.OVERVIEW_GAME, - game - }); - } catch (error) { - fastify.log.error({ err: error }, "Error in SUBMITTED_PLAYER"); - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.ERROR, - message: "Failed to update submission" - }); - } - break; - } - - case gameEventEnum.SEND_MESSAGE: { - // Check if user is banned before allowing chat - const banStatus = await checkUserBanStatus(req.user.userId); - if (banStatus.isBanned && banStatus.ban) { - userWebSockets.updateUser(req.user.username, { - event: gameEventEnum.ERROR, - message: `Cannot send message: You are banned. ${banStatus.ban.reason}` - }); - break; - } - - // Persist chat message to database - let chatMessageId; - try { - const chatMessage = new ChatMessageModel({ - gameId: id, - userId: req.user.userId, - username: req.user.username, - message: parsedMessage.chatMessage.message - }); - const savedMessage = await chatMessage.save(); - chatMessageId = String(savedMessage._id); - } catch (error) { - fastify.log.error({ err: error }, "Failed to save chat message"); - } - - const updatedChatMessage: ChatMessage = { - ...parsedMessage.chatMessage, - _id: chatMessageId, - createdAt: new Date().toISOString() - }; - - userWebSockets.updateAllUsers({ - event: gameEventEnum.SEND_MESSAGE, - chatMessage: updatedChatMessage - }); - break; - } - - case gameEventEnum.CHANGE_LANGUAGE: { - const language = parsedMessage.language; - - if (!language) { - return; - } - - userWebSockets.updateAllUsers({ - event: gameEventEnum.CHANGE_LANGUAGE, - language, - username: req.user.username - }); - break; - } - - default: - parsedMessage satisfies never; - break; - } - }); - - socket.on("close", (code, reason) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - fastify.log.info( - { username: req.user.username, code, reason: reason.toString() }, - "Game socket closed" - ); - userWebSockets.remove(req.user.username); - }); - - socket.on("error", (error) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - fastify.log.error( - { err: error }, - `Game socket error for ${req.user.username}` - ); - userWebSockets.remove(req.user.username); - }); -} diff --git a/libs/backend/src/websocket/game/on-connection.ts b/libs/backend/src/websocket/game/on-connection.ts deleted file mode 100644 index 146c2792..00000000 --- a/libs/backend/src/websocket/game/on-connection.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - AuthenticatedInfo, - ERROR_MESSAGES, - gameEventEnum, - getUserIdFromUser, - isGameDto, - isPuzzleDto, - isString, - ObjectId, - websocketCloseCodes -} from "types"; -import { UserWebSockets } from "./user-web-sockets.js"; -import { WebSocket } from "@fastify/websocket"; -import { gameService } from "@/services/game.service.js"; -import { puzzleService } from "@/services/puzzle.service.js"; - -export async function onConnection( - userWebSockets: UserWebSockets, - user: AuthenticatedInfo, - gameId: ObjectId, - socket: WebSocket -): Promise { - try { - const game = await gameService.findByIdPopulated(gameId); - - if (!isGameDto(game)) { - socket.send( - JSON.stringify({ - event: gameEventEnum.NONEXISTENT_GAME, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }) - ); - socket.close( - websocketCloseCodes.POLICY_VIOLATION, - ERROR_MESSAGES.GAME.NOT_FOUND - ); - return; - } - - const isPlayerInGame = game.players.some( - (player) => getUserIdFromUser(player) === user.userId - ); - - if (!isPlayerInGame) { - socket.send( - JSON.stringify({ - event: gameEventEnum.OVERVIEW_GAME, - game - }) - ); - socket.send( - JSON.stringify({ - event: gameEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME - }) - ); - socket.close( - websocketCloseCodes.POLICY_VIOLATION, - ERROR_MESSAGES.GAME.USER_NOT_IN_GAME - ); - return; - } - - userWebSockets.add(user.username, socket, user); - - const isGameFinished = game.endTime < new Date(); - if (isGameFinished) { - userWebSockets.updateUser(user.username, { - event: gameEventEnum.FINISHED_GAME, - game - }); - return; - } - - const puzzleId = isString(game.puzzle) - ? game.puzzle - : game.puzzle._id.toString(); - const puzzle = await puzzleService.findByIdPopulated(puzzleId); - - if (!isPuzzleDto(puzzle)) { - userWebSockets.updateUser(user.username, { - event: gameEventEnum.ERROR, - message: ERROR_MESSAGES.PUZZLE.NOT_FOUND - }); - return; - } - - userWebSockets.updateUser(user.username, { - event: gameEventEnum.OVERVIEW_GAME, - game, - puzzle - }); - } catch (error) { - console.error("Error in game websocket connection:", error); - socket.close( - websocketCloseCodes.INTERNAL_ERROR, - ERROR_MESSAGES.SERVER.INTERNAL_ERROR - ); - } -} diff --git a/libs/backend/src/websocket/game/user-web-sockets.ts b/libs/backend/src/websocket/game/user-web-sockets.ts deleted file mode 100644 index 13edc503..00000000 --- a/libs/backend/src/websocket/game/user-web-sockets.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { WebSocket } from "@fastify/websocket"; -import { AuthenticatedInfo, GameResponse } from "types"; -import { ConnectionManager } from "../connection-manager.js"; - -type Username = string; - -export class UserWebSockets { - private connectionManager: ConnectionManager; - - constructor() { - this.connectionManager = new ConnectionManager({ - onConnectionLost: (username: string) => { - console.info(`Game connection lost for user: ${username}`); - } - }); - } - - add(username: Username, socket: WebSocket, user: AuthenticatedInfo): void { - this.connectionManager.add(user, socket); - } - - remove(username: Username): void { - this.connectionManager.remove(username); - } - - updateAllUsers(response: GameResponse): void { - const usernames = this.connectionManager.getAllUsernames(); - usernames.forEach((username: string) => { - this.updateUser(username, response); - }); - } - - updateUser(username: string, response: GameResponse): boolean { - return this.connectionManager.send(username, response); - } - - isConnected(username: Username): boolean { - return this.connectionManager.isConnected(username); - } - - getConnectionCount(): number { - return this.connectionManager.getConnectionCount(); - } - - destroy(): void { - this.connectionManager.destroy(); - } -} diff --git a/libs/backend/src/websocket/waiting-room/on-connection.ts b/libs/backend/src/websocket/waiting-room/on-connection.ts deleted file mode 100644 index 0d7a5dc4..00000000 --- a/libs/backend/src/websocket/waiting-room/on-connection.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { WebSocket } from "@fastify/websocket"; -import { AuthenticatedInfo, waitingRoomEventEnum } from "types"; -import { WaitingRoom } from "./waiting-room.js"; - -export function onConnection( - waitingRoom: WaitingRoom, - socket: WebSocket, - user: AuthenticatedInfo -): void { - waitingRoom.addUserToUsers(user.username, socket, user); - - const openRooms = waitingRoom.getRooms(); - waitingRoom.updateUser(user.username, { - event: waitingRoomEventEnum.OVERVIEW_OF_ROOMS, - rooms: openRooms - }); -} diff --git a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts b/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts deleted file mode 100644 index 1e9a0254..00000000 --- a/libs/backend/src/websocket/waiting-room/waiting-room-setup.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { WebSocket } from "@fastify/websocket"; -import { FastifyInstance, FastifyRequest } from "fastify"; -import { - DEFAULT_GAME_LENGTH_IN_MILLISECONDS, - ERROR_MESSAGES, - frontendUrls, - GameEntity, - gameModeEnum, - gameVisibilityEnum, - isAuthenticatedInfo, - waitingRoomEventEnum -} from "types"; -import { WaitingRoom } from "./waiting-room.js"; -import { onConnection as onWaitingRoomConnection } from "./on-connection.js"; -import { parseRawDataWaitingRoomRequest } from "@/utils/functions/parse-raw-data-message.js"; -import { puzzleService } from "@/services/puzzle.service.js"; -import { gameService } from "@/services/game.service.js"; - -const waitingRoom = new WaitingRoom(); - -export function waitingRoomSetup( - socket: WebSocket, - req: FastifyRequest, - fastify: FastifyInstance -) { - if (!isAuthenticatedInfo(req.user)) { - socket.close(1008, ERROR_MESSAGES.AUTHENTICATION.AUTHENTICATION_REQUIRED); - return; - } - - onWaitingRoomConnection(waitingRoom, socket, req.user); - - // Handle ping from client - socket.on("ping", () => { - socket.pong(); - }); - - socket.on("message", async (message) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - - let parsedMessage; - - try { - parsedMessage = parseRawDataWaitingRoomRequest(message); - } catch (e) { - const error = e as Error; - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: error.message - }); - return; - } - - const { event } = parsedMessage; - - switch (event) { - case waitingRoomEventEnum.HOST_ROOM: { - const roomId = waitingRoom.hostRoom(req.user, parsedMessage.options); - fastify.log.info( - { username: req.user.username, roomId }, - "User hosted room" - ); - break; - } - - case waitingRoomEventEnum.JOIN_ROOM: { - const success = waitingRoom.joinRoom(req.user, parsedMessage.roomId); - if (!success) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - } - break; - } - - case waitingRoomEventEnum.JOIN_BY_INVITE_CODE: { - const roomId = waitingRoom.getRoomByInviteCode( - parsedMessage.inviteCode - ); - if (!roomId) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: `Invalid invite code: ${parsedMessage.inviteCode}` - }); - break; - } - - const success = waitingRoom.joinRoom(req.user, roomId); - if (!success) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.FAILED_TO_START - }); - } - break; - } - - case waitingRoomEventEnum.LEAVE_ROOM: { - waitingRoom.leaveRoom(req.user.username, parsedMessage.roomId); - break; - } - - case waitingRoomEventEnum.CHAT_MESSAGE: { - const room = waitingRoom.getRoom(parsedMessage.roomId); - - if (!room) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - break; - } - - const userInRoom = req.user.username in room; - - if (!userInRoom) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME - }); - break; - } - - waitingRoom.updateUsersInRoom(parsedMessage.roomId, { - event: waitingRoomEventEnum.CHAT_MESSAGE, - username: req.user.username, - message: parsedMessage.message, - createdAt: new Date() - }); - break; - } - - case waitingRoomEventEnum.START_GAME: { - try { - const randomPuzzles = await puzzleService.findRandomApproved(1); - - if (randomPuzzles.length < 1) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.NOT_ENOUGH_PUZZLES, - message: "Create a puzzle and get it approved to play multiplayer" - }); - return; - } - - const randomPuzzle = randomPuzzles[0]; - const room = waitingRoom.getRoom(parsedMessage.roomId); - - if (!room) { - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.NOT_FOUND - }); - return; - } - - const players = Object.values(room).map((player) => player.userId); - - if (players.length <= 0) { - waitingRoom.removeEmptyRooms(); - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.USER_NOT_IN_GAME - }); - return; - } - - const now = new Date(); - const roomOptions = waitingRoom.getRoomOptions(parsedMessage.roomId); - const gameDuration = - roomOptions?.maxGameDurationInSeconds ?? - DEFAULT_GAME_LENGTH_IN_MILLISECONDS / 1000; - const gameDurationMs = gameDuration * 1000; - - const countdownSeconds = 15; - const startTime = new Date(now.getTime() + countdownSeconds * 1000); - const endTime = new Date(startTime.getTime() + gameDurationMs); - - const createGameEntity: GameEntity = { - players, - owner: waitingRoom.findRoomOwner(room).userId, - puzzle: randomPuzzle._id.toString(), - createdAt: now, - startTime, - endTime, - options: { - allowedLanguages: [], - maxGameDurationInSeconds: gameDuration, - mode: gameModeEnum.FASTEST, - visibility: gameVisibilityEnum.PUBLIC, - rated: true, - ...roomOptions - }, - playerSubmissions: [] - }; - const newlyCreatedGame = await gameService.create(createGameEntity); - const gameUrl = frontendUrls.multiplayerById(newlyCreatedGame.id); - - // Store the pending game start state in the room - waitingRoom.setPendingGameStart( - parsedMessage.roomId, - gameUrl, - startTime - ); - - waitingRoom.updateUsersInRoom(parsedMessage.roomId, { - event: waitingRoomEventEnum.START_GAME, - gameUrl, - startTime - }); - - fastify.log.info( - { - gameId: newlyCreatedGame.id, - playerCount: players.length, - startTime, - countdownSeconds - }, - "Game created with countdown" - ); - return; - } catch (error) { - fastify.log.error({ err: error }, "Error starting game"); - waitingRoom.updateUser(req.user.username, { - event: waitingRoomEventEnum.ERROR, - message: ERROR_MESSAGES.GAME.FAILED_TO_START - }); - } - break; - } - - default: - event satisfies never; - break; - } - - const joinableRooms = waitingRoom.getRooms(); - waitingRoom.updateAllUsers({ - event: waitingRoomEventEnum.OVERVIEW_OF_ROOMS, - rooms: joinableRooms - }); - }); - - socket.on("close", (code, reason) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - fastify.log.info( - { username: req.user.username, code, reason: reason.toString() }, - "Waiting room socket closed" - ); - waitingRoom.removeUserFromUsers(req.user.username); - waitingRoom.removeEmptyRooms(); - }); - - socket.on("error", (error) => { - if (!isAuthenticatedInfo(req.user)) { - return; - } - fastify.log.error( - { err: error }, - `Waiting room socket error for ${req.user.username}` - ); - waitingRoom.removeUserFromUsers(req.user.username); - waitingRoom.removeEmptyRooms(); - }); -} diff --git a/libs/backend/src/websocket/waiting-room/waiting-room.ts b/libs/backend/src/websocket/waiting-room/waiting-room.ts deleted file mode 100644 index 7386e413..00000000 --- a/libs/backend/src/websocket/waiting-room/waiting-room.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { WebSocket } from "@fastify/websocket"; -import mongoose from "mongoose"; -import { - AuthenticatedInfo, - GameOptions, - GameUserInfo, - ObjectId, - waitingRoomEventEnum, - WaitingRoomResponse -} from "types"; -import { ConnectionManager } from "../connection-manager.js"; - -type Username = string; -type RoomId = ObjectId; -type Room = Record; - -interface RoomConfig { - users: Room; - options?: GameOptions | undefined; - inviteCode?: string | undefined; - pendingGameStart?: { gameUrl: string; startTime: Date } | undefined; -} - -export class WaitingRoom { - private roomsByRoomId: Record; - private roomsByUsername: Record; - private connectionManager: ConnectionManager; - - constructor() { - this.roomsByRoomId = {}; - this.roomsByUsername = {}; - this.connectionManager = new ConnectionManager({ - onConnectionLost: (username) => { - this.handleDisconnectedUser(username); - } - }); - } - - private handleDisconnectedUser(username: Username): void { - console.info(`Waiting room connection lost for user: ${username}`); - const roomId = this.roomsByUsername[username]; - if (roomId) { - this.leaveRoom(username, roomId); - } - this.removeEmptyRooms(); - } - - addUserToUsers( - username: Username, - socket: WebSocket, - user: AuthenticatedInfo - ): void { - this.connectionManager.add(user, socket); - } - - removeUserFromUsers(username: Username): void { - const roomId = this.roomsByUsername[username]; - if (roomId) { - this.leaveRoom(username, roomId); - } - this.connectionManager.remove(username); - } - - hostRoom(user: AuthenticatedInfo, options?: GameOptions): RoomId { - const randomId = new mongoose.Types.ObjectId().toString(); - - // Generate a 6-character invite code for private rooms - let inviteCode: string | undefined; - if (options?.visibility === "private") { - inviteCode = this.generateInviteCode(); - } - - this.roomsByRoomId[randomId] = { - users: { - [user.username]: { - joinedAt: new Date(), - userId: user.userId, - username: user.username - } - }, - options, - inviteCode - }; - - this.joinRoom(user, randomId); - return randomId; - } - - private generateInviteCode(): string { - // Generate a random 6-character code using uppercase letters and numbers - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let code = ""; - for (let i = 0; i < 6; i++) { - code += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return code; - } - - joinRoom(user: AuthenticatedInfo, roomId: RoomId): boolean { - const roomConfig = this.roomsByRoomId[roomId]; - if (!roomConfig) { - console.warn( - `Room ${roomId} not found when user ${user.username} tried to join` - ); - return false; - } - - roomConfig.users[user.username] = { - joinedAt: new Date(), - userId: user.userId, - username: user.username - }; - - this.roomsByUsername[user.username] = roomId; - console.info(`User ${user.username} joined room ${roomId}`); - this.updateUsersOnRoomState(roomId); - - // If there's a pending game start, notify the newly joined user - if (roomConfig.pendingGameStart) { - this.updateUser(user.username, { - event: waitingRoomEventEnum.START_GAME, - gameUrl: roomConfig.pendingGameStart.gameUrl, - startTime: roomConfig.pendingGameStart.startTime - }); - } - - return true; - } - - leaveRoom(username: Username, roomId: RoomId): void { - const roomConfig = this.roomsByRoomId[roomId]; - if (!roomConfig) { - console.warn( - `Room ${roomId} not found when user ${username} tried to leave` - ); - return; - } - - delete roomConfig.users[username]; - delete this.roomsByUsername[username]; - console.info( - `User ${username} left room ${roomId}. Remaining players: ${Object.keys(roomConfig.users).length}` - ); - - if (Object.keys(roomConfig.users).length <= 0) { - delete this.roomsByRoomId[roomId]; - console.info(`Room ${roomId} is now empty and removed`); - } else { - this.updateUsersOnRoomState(roomId); - } - } - - getRoom(roomId: RoomId): Room | undefined { - const roomConfig = this.roomsByRoomId[roomId]; - return roomConfig?.users; - } - - getRoomOptions(roomId: RoomId): GameOptions | undefined { - return this.roomsByRoomId[roomId]?.options; - } - - getRooms(): Array<{ roomId: RoomId; amountOfPlayersJoined: number }> { - // Only return public rooms - return Object.entries(this.roomsByRoomId) - .filter(([_roomId, roomConfig]) => { - return roomConfig.options?.visibility !== "private"; - }) - .map(([roomId, roomConfig]) => { - return { - roomId, - amountOfPlayersJoined: Object.keys(roomConfig.users).length - }; - }); - } - - getRoomByInviteCode(inviteCode: string): RoomId | undefined { - const entry = Object.entries(this.roomsByRoomId).find( - ([_roomId, roomConfig]) => roomConfig.inviteCode === inviteCode - ); - return entry?.[0]; - } - - getInviteCode(roomId: RoomId): string | undefined { - return this.roomsByRoomId[roomId]?.inviteCode; - } - - getAllRoomIds(): RoomId[] { - return Object.keys(this.roomsByRoomId); - } - - setPendingGameStart(roomId: RoomId, gameUrl: string, startTime: Date): void { - const roomConfig = this.roomsByRoomId[roomId]; - if (roomConfig) { - roomConfig.pendingGameStart = { gameUrl, startTime }; - } - } - - updateUsersOnRoomState(roomId: RoomId): void { - const room = this.getRoom(roomId); - const inviteCode = this.getInviteCode(roomId); - if (!room) { - return; - } - - const usersInRoom = Object.values(room); - this.updateUsersInRoom(roomId, { - event: waitingRoomEventEnum.OVERVIEW_ROOM, - room: { - users: usersInRoom, - owner: this.findRoomOwner(room), - roomId, - ...(inviteCode && { inviteCode }) - } - }); - } - - findRoomOwner(room: Room): GameUserInfo { - const usersInRoom = Object.values(room); - return usersInRoom.sort((userA, userB) => { - const userAJoinDate = new Date(userA.joinedAt).getTime(); - const userBJoinDate = new Date(userB.joinedAt).getTime(); - return userAJoinDate - userBJoinDate; - })[0]; - } - - updateUsersInRoom(roomId: RoomId, response: WaitingRoomResponse): void { - const room = this.getRoom(roomId); - if (!room) { - return; - } - - Object.keys(room).forEach((username) => { - this.updateUser(username, response); - }); - } - - updateAllUsers(response: WaitingRoomResponse): void { - const usernames = this.connectionManager.getAllUsernames(); - usernames.forEach((username) => { - this.updateUser(username, response); - }); - } - - updateUser(username: string, response: WaitingRoomResponse): boolean { - return this.connectionManager.send(username, response); - } - - removeEmptyRooms(): void { - const emptyRoomIds = Object.entries(this.roomsByRoomId) - .filter( - ([_roomId, roomConfig]) => Object.keys(roomConfig.users).length === 0 - ) - .map(([roomId]) => roomId); - - emptyRoomIds.forEach((roomId) => { - console.info(`Removing empty room: ${roomId}`); - delete this.roomsByRoomId[roomId]; - }); - - if (emptyRoomIds.length > 0) { - console.info(`Removed ${emptyRoomIds.length} empty rooms`); - } - } - - dissolveRoom(roomId: RoomId): void { - const room = this.getRoom(roomId); - if (!room) return; - - const usernames = Object.keys(room); - - usernames.forEach((username) => { - delete this.roomsByUsername[username]; - }); - delete this.roomsByRoomId[roomId]; - - usernames.forEach((username) => { - this.connectionManager.remove(username); - }); - - console.info(`Dissolved room ${roomId} with ${usernames.length} users`); - } - - isUserConnected(username: Username): boolean { - return this.connectionManager.isConnected(username); - } - - destroy(): void { - this.connectionManager.destroy(); - } -} diff --git a/libs/backend/tsconfig.json b/libs/backend/tsconfig.json deleted file mode 100644 index 99931b1c..00000000 --- a/libs/backend/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "extends": "@tsconfig/recommended/tsconfig.json", - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "esModuleInterop": true, - "strict": true, - "resolveJsonModule": true, - "removeComments": true, - "newLine": "lf", - "noUnusedLocals": true, - "noFallthroughCasesInSwitch": true, - "isolatedModules": true, - "forceConsistentCasingInFileNames": true, - "exactOptionalPropertyTypes": true, - "skipLibCheck": true, - "lib": ["ESNext"], - - "paths": { - "#/*": ["./src/lib/components"], - "@/*": ["./src/*"] - } - } -} diff --git a/libs/backend/validate_migration.exs b/libs/backend/validate_migration.exs new file mode 100644 index 00000000..aacda368 --- /dev/null +++ b/libs/backend/validate_migration.exs @@ -0,0 +1,599 @@ +#!/usr/bin/env elixir + +# Mix.install([{:mongodb_driver, "~> 1.0"}]) + +""" +Migration Validation Script +=========================== + +This script validates that data was successfully migrated from MongoDB to PostgreSQL. +It compares counts and samples data from both databases. + +Usage: + mix run validate_migration.exs + mix run validate_migration.exs --detailed + mix run validate_migration.exs --export-report + +Requirements: +- PostgreSQL must be running with migrated data +- MongoDB must be accessible (optional, for comparison) +""" + +defmodule MigrationValidator do + @moduledoc """ + Validates the migration from MongoDB to PostgreSQL by comparing data counts, + checking data integrity, and generating a detailed report. + """ + + require Logger + + alias CodincodApi.Repo + alias CodincodApi.Accounts.User + alias CodincodApi.Puzzles.Puzzle + alias CodincodApi.Submissions.Submission + alias CodincodApi.Games.Game + alias CodincodApi.Languages.ProgrammingLanguage + alias CodincodApi.Comments.Comment + alias CodincodApi.Moderation.Report + + import Ecto.Query + + def run(opts \\ []) do + IO.puts("\n" <> header()) + + case validate_all(opts) do + {:ok, report} -> + display_report(report, opts) + maybe_export_report(report, opts) + IO.puts(success_message()) + {:ok, report} + + {:error, reason} -> + IO.puts(error_message(reason)) + {:error, reason} + end + end + + def validate_all(opts) do + try do + mongo_available = opts[:skip_mongo] != true && check_mongo_connection() + + results = [ + validate_users(mongo_available), + validate_puzzles(mongo_available), + validate_submissions(mongo_available), + validate_games(mongo_available), + validate_languages(mongo_available), + validate_comments(mongo_available), + validate_reports(mongo_available), + validate_data_integrity(), + validate_indexes(), + validate_constraints() + ] + + report = %{ + timestamp: DateTime.utc_now(), + mongo_available: mongo_available, + results: results, + summary: summarize_results(results) + } + + {:ok, report} + rescue + e -> + {:error, Exception.message(e)} + end + end + + ## Validation Functions + + defp validate_users(mongo_available) do + pg_count = Repo.aggregate(User, :count) + mongo_count = if mongo_available, do: get_mongo_count("users"), else: nil + + sample_users = User |> limit(5) |> Repo.all() + + %{ + entity: "Users", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + samples: length(sample_users), + details: %{ + with_email: count_users_with_email(), + with_username: count_users_with_username(), + admin_users: count_admin_users(), + banned_users: count_banned_users() + } + } + end + + defp validate_puzzles(mongo_available) do + pg_count = Repo.aggregate(Puzzle, :count) + mongo_count = if mongo_available, do: get_mongo_count("puzzles"), else: nil + + %{ + entity: "Puzzles", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + published: count_published_puzzles(), + draft: count_draft_puzzles(), + by_difficulty: count_by_difficulty() + } + } + end + + defp validate_submissions(mongo_available) do + pg_count = Repo.aggregate(Submission, :count) + mongo_count = if mongo_available, do: get_mongo_count("submissions"), else: nil + + %{ + entity: "Submissions", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + accepted: count_accepted_submissions(), + rejected: count_rejected_submissions(), + pending: count_pending_submissions() + } + } + end + + defp validate_games(mongo_available) do + pg_count = Repo.aggregate(Game, :count) + mongo_count = if mongo_available, do: get_mongo_count("games"), else: nil + + %{ + entity: "Games", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + completed: count_completed_games(), + in_progress: count_in_progress_games(), + waiting: count_waiting_games() + } + } + end + + defp validate_languages(mongo_available) do + pg_count = Repo.aggregate(ProgrammingLanguage, :count) + mongo_count = if mongo_available, do: get_mongo_count("languages"), else: nil + + %{ + entity: "Programming Languages", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + active: count_active_languages() + } + } + end + + defp validate_comments(mongo_available) do + pg_count = Repo.aggregate(Comment, :count) + mongo_count = if mongo_available, do: get_mongo_count("comments"), else: nil + + %{ + entity: "Comments", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{} + } + end + + defp validate_reports(mongo_available) do + pg_count = Repo.aggregate(Report, :count) + mongo_count = if mongo_available, do: get_mongo_count("reports"), else: nil + + %{ + entity: "Reports", + pg_count: pg_count, + mongo_count: mongo_count, + match: mongo_count == nil || pg_count >= mongo_count, + details: %{ + pending: count_pending_reports(), + resolved: count_resolved_reports() + } + } + end + + defp validate_data_integrity do + checks = [ + check_orphaned_submissions(), + check_orphaned_games(), + check_orphaned_comments(), + check_duplicate_usernames(), + check_duplicate_emails(), + check_invalid_references() + ] + + passed = Enum.count(checks, & &1.passed) + total = length(checks) + + %{ + entity: "Data Integrity", + checks: checks, + passed: passed, + total: total, + match: passed == total + } + end + + defp validate_indexes do + # Check if critical indexes exist + indexes = get_table_indexes() + + %{ + entity: "Database Indexes", + indexes: indexes, + match: true + } + end + + defp validate_constraints do + # Check if foreign key constraints are in place + constraints = get_foreign_key_constraints() + + %{ + entity: "Foreign Key Constraints", + constraints: constraints, + match: true + } + end + + ## Helper Functions - Counts + + defp count_users_with_email do + User |> where([u], not is_nil(u.email)) |> Repo.aggregate(:count) + end + + defp count_users_with_username do + User |> where([u], not is_nil(u.username)) |> Repo.aggregate(:count) + end + + defp count_admin_users do + User |> where([u], u.role == :admin) |> Repo.aggregate(:count) + end + + defp count_banned_users do + User |> where([u], not is_nil(u.current_ban_id)) |> Repo.aggregate(:count) + end + + defp count_published_puzzles do + Puzzle |> where([p], p.is_published == true) |> Repo.aggregate(:count) + end + + defp count_draft_puzzles do + Puzzle |> where([p], p.is_published == false) |> Repo.aggregate(:count) + end + + defp count_by_difficulty do + Puzzle + |> group_by([p], p.difficulty) + |> select([p], {p.difficulty, count(p.id)}) + |> Repo.all() + |> Enum.into(%{}) + end + + defp count_accepted_submissions do + Submission |> where([s], s.status == "accepted") |> Repo.aggregate(:count) + end + + defp count_rejected_submissions do + Submission |> where([s], s.status == "rejected") |> Repo.aggregate(:count) + end + + defp count_pending_submissions do + Submission |> where([s], s.status == "pending") |> Repo.aggregate(:count) + end + + defp count_completed_games do + Game |> where([g], g.status == "completed") |> Repo.aggregate(:count) + end + + defp count_in_progress_games do + Game |> where([g], g.status == "in_progress") |> Repo.aggregate(:count) + end + + defp count_waiting_games do + Game |> where([g], g.status == "waiting") |> Repo.aggregate(:count) + end + + defp count_active_languages do + ProgrammingLanguage |> where([l], l.is_active == true) |> Repo.aggregate(:count) + end + + defp count_pending_reports do + Report |> where([r], r.status == "pending") |> Repo.aggregate(:count) + end + + defp count_resolved_reports do + Report |> where([r], r.status == "resolved") |> Repo.aggregate(:count) + end + + ## Helper Functions - Integrity Checks + + defp check_orphaned_submissions do + # Check for submissions without valid user or puzzle references + orphaned = + Submission + |> join(:left, [s], u in User, on: s.user_id == u.id) + |> join(:left, [s], p in Puzzle, on: s.puzzle_id == p.id) + |> where([s, u, p], is_nil(u.id) or is_nil(p.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Submissions", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_orphaned_games do + orphaned = + Game + |> join(:left, [g], u in User, on: g.owner_id == u.id) + |> join(:left, [g], p in Puzzle, on: g.puzzle_id == p.id) + |> where([g, u, p], is_nil(u.id) or is_nil(p.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Games", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_orphaned_comments do + orphaned = + Comment + |> join(:left, [c], u in User, on: c.author_id == u.id) + |> where([c, u], is_nil(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Orphaned Comments", + passed: orphaned == 0, + count: orphaned + } + end + + defp check_duplicate_usernames do + duplicates = + User + |> group_by([u], u.username) + |> having([u], count(u.id) > 1) + |> select([u], count(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Duplicate Usernames", + passed: duplicates == 0, + count: duplicates + } + end + + defp check_duplicate_emails do + duplicates = + User + |> group_by([u], u.email) + |> having([u], count(u.id) > 1) + |> select([u], count(u.id)) + |> Repo.aggregate(:count) + + %{ + name: "Duplicate Emails", + passed: duplicates == 0, + count: duplicates + } + end + + defp check_invalid_references do + # This is a placeholder - add specific checks as needed + %{ + name: "Invalid References", + passed: true, + count: 0 + } + end + + ## Database Introspection + + defp get_table_indexes do + query = """ + SELECT + tablename, + indexname, + indexdef + FROM pg_indexes + WHERE schemaname = 'public' + ORDER BY tablename, indexname + """ + + case Repo.query(query) do + {:ok, %{rows: rows}} -> length(rows) + _ -> 0 + end + end + + defp get_foreign_key_constraints do + query = """ + SELECT + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + """ + + case Repo.query(query) do + {:ok, %{rows: rows}} -> length(rows) + _ -> 0 + end + end + + ## MongoDB Functions (placeholder) + + defp check_mongo_connection do + # TODO: Implement MongoDB connection check + # This would require MongoDB driver to be installed + false + end + + defp get_mongo_count(_collection) do + # TODO: Implement MongoDB count + nil + end + + ## Report Functions + + defp summarize_results(results) do + total_entities = length(results) + + matches = + Enum.count(results, fn result -> + Map.get(result, :match, false) + end) + + %{ + total_entities: total_entities, + matched: matches, + percentage: if(total_entities > 0, do: matches / total_entities * 100, else: 0) + } + end + + defp display_report(report, opts) do + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts("MIGRATION VALIDATION REPORT") + IO.puts(String.duplicate("=", 80)) + IO.puts("Timestamp: #{report.timestamp}") + IO.puts("MongoDB Available: #{report.mongo_available}") + IO.puts("") + + Enum.each(report.results, fn result -> + display_result(result, opts) + end) + + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts("SUMMARY") + IO.puts(String.duplicate("=", 80)) + + summary = report.summary + IO.puts("Entities Validated: #{summary.total_entities}") + IO.puts("Matched: #{summary.matched}") + IO.puts("Success Rate: #{Float.round(summary.percentage, 2)}%") + IO.puts("") + end + + defp display_result(result, opts) do + entity = result.entity + pg_count = Map.get(result, :pg_count, "N/A") + mongo_count = Map.get(result, :mongo_count, "N/A") + match = Map.get(result, :match, false) + + status = if match, do: "✓", else: "✗" + IO.puts("\n#{status} #{entity}") + IO.puts(" PostgreSQL: #{pg_count}") + + if mongo_count != "N/A" do + IO.puts(" MongoDB: #{mongo_count}") + end + + if opts[:detailed] && Map.has_key?(result, :details) do + display_details(result.details) + end + + if Map.has_key?(result, :checks) do + display_checks(result.checks) + end + end + + defp display_details(details) do + IO.puts(" Details:") + + Enum.each(details, fn {key, value} -> + IO.puts(" #{key}: #{inspect(value)}") + end) + end + + defp display_checks(checks) do + IO.puts(" Integrity Checks:") + + Enum.each(checks, fn check -> + status = if check.passed, do: "✓", else: "✗" + IO.puts(" #{status} #{check.name}: #{check.count}") + end) + end + + defp maybe_export_report(report, opts) do + if opts[:export_report] do + filename = "migration_report_#{DateTime.to_unix(report.timestamp)}.json" + content = Jason.encode!(report, pretty: true) + File.write!(filename, content) + IO.puts("\n✓ Report exported to: #{filename}") + end + end + + ## Messages + + defp header do + """ + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ CodinCod Migration Validation Tool ║ + ║ ║ + ║ MongoDB → PostgreSQL Data Validation ║ + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end + + defp success_message do + """ + + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ✓ Validation Complete! ║ + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end + + defp error_message(reason) do + """ + + ╔══════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ✗ Validation Failed ║ + ║ ║ + ║ Error: #{reason} + ║ ║ + ╚══════════════════════════════════════════════════════════════════════════╝ + """ + end +end + +# Parse command line arguments +args = System.argv() +opts = [ + detailed: Enum.member?(args, "--detailed"), + export_report: Enum.member?(args, "--export-report"), + skip_mongo: Enum.member?(args, "--skip-mongo") +] + +# Run validation +MigrationValidator.run(opts) diff --git a/libs/backend/vitest.config.js b/libs/backend/vitest.config.js deleted file mode 100644 index 6c3bf813..00000000 --- a/libs/backend/vitest.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; -import tsconfigPaths from "vite-tsconfig-paths"; - -export default defineConfig({ - plugins: [tsconfigPaths()], - test: { - environment: "node", - globals: true - // setupFiles: ["./src/tests/setup.ts"] // optional setup file - } -}); diff --git a/libs/elixir-backend/codincod_api/priv/repo/seeds.exs b/libs/elixir-backend/codincod_api/priv/repo/seeds.exs new file mode 100644 index 00000000..2e945373 --- /dev/null +++ b/libs/elixir-backend/codincod_api/priv/repo/seeds.exs @@ -0,0 +1,433 @@ +# Script for populating the database with test data +# +# Run with: mix run priv/repo/seeds.exs +# +# This will create test users, puzzles, and other data for development + +alias CodincodApi.Repo +alias CodincodApi.Accounts +alias CodincodApi.Accounts.User +alias CodincodApi.Puzzles +alias CodincodApi.Puzzles.{Puzzle, PuzzleValidator} +alias CodincodApi.Languages +alias CodincodApi.Languages.ProgrammingLanguage + +require Logger + +# Helper to safely insert or find existing record +defmodule SeedHelpers do + def insert_or_find(module, attrs, unique_field) do + case Repo.get_by(module, [{unique_field, Map.get(attrs, unique_field)}]) do + nil -> + %{module.__struct__() | id: Ecto.UUID.generate()} + |> module.changeset(attrs) + |> Repo.insert!() + + existing -> + Logger.info("#{module} with #{unique_field}=#{Map.get(attrs, unique_field)} already exists") + existing + end + end +end + +Logger.info("🌱 Starting seed process...") + +# ============================================================================ +# PROGRAMMING LANGUAGES +# ============================================================================ +Logger.info("Creating programming languages...") + +languages = [ + %{ + name: "python", + version: "3.12.0", + runtime: "python", + piston_name: "python" + }, + %{ + name: "javascript", + version: "18.15.0", + runtime: "node", + piston_name: "javascript" + }, + %{ + name: "ruby", + version: "3.2.0", + runtime: "ruby", + piston_name: "ruby" + }, + %{ + name: "rust", + version: "1.68.2", + runtime: "rust", + piston_name: "rust" + }, + %{ + name: "elixir", + version: "1.14.0", + runtime: "elixir", + piston_name: "elixir" + }, + %{ + name: "go", + version: "1.21.0", + runtime: "go", + piston_name: "go" + } +] + +_created_languages = + Enum.map(languages, fn lang_attrs -> + SeedHelpers.insert_or_find(ProgrammingLanguage, lang_attrs, :name) + end) + +# ============================================================================ +# TEST USERS +# ============================================================================ +Logger.info("Creating test users...") + +# Main test user (matches mongo_testdata.py) +codincoder = + SeedHelpers.insert_or_find( + User, + %{ + username: "codincoder", + email: "codincoder@example.com", + password: "strongpassword123!", + password_confirmation: "strongpassword123!", + profile: %{ + bio: "I love coding challenges!", + location: "Code City", + picture: nil, + socials: %{ + github: "codincoder", + twitter: "codincoder" + } + }, + role: "user" + }, + :username + ) + +# Additional test users for variety +alice = + SeedHelpers.insert_or_find( + User, + %{ + username: "alice", + email: "alice@example.com", + password: "alicepassword123!", + password_confirmation: "alicepassword123!", + profile: %{ + bio: "Algorithm enthusiast", + location: "Wonderland" + }, + role: "user" + }, + :username + ) + +bob = + SeedHelpers.insert_or_find( + User, + %{ + username: "bob", + email: "bob@example.com", + password: "bobpassword123!", + password_confirmation: "bobpassword123!", + profile: %{ + bio: "Puzzle solver extraordinaire" + }, + role: "user" + }, + :username + ) + +moderator = + SeedHelpers.insert_or_find( + User, + %{ + username: "moderator", + email: "moderator@example.com", + password: "modpassword123!", + password_confirmation: "modpassword123!", + profile: %{ + bio: "Keeping the platform safe" + }, + role: "moderator" + }, + :username + ) + +# ============================================================================ +# PUZZLES +# ============================================================================ +Logger.info("Creating test puzzles...") + +# Easy puzzle - Print 42 +easy_puzzle = + case Repo.get_by(Puzzle, title: "Print 42") do + nil -> + puzzle_attrs = %{ + title: "Print 42", + statement: "Print the number 42.", + constraints: "No input required", + difficulty: "BEGINNER", + visibility: "APPROVED", + tags: ["beginner", "output"], + solution: %{ + code: "print(42)", + language: "python", + languageVersion: "3.12.0" + }, + author_id: codincoder.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + # Add validators + validators = [ + %{input: "", output: "42"}, + %{input: "", output: "42"}, + %{input: "", output: "42"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Print 42' already exists") + existing + end + +# FizzBuzz puzzle (from mongo_testdata.py) +fizzbuzz_puzzle = + case Repo.get_by(Puzzle, title: "FizzBuzz") do + nil -> + puzzle_attrs = %{ + title: "FizzBuzz", + statement: """ + Print numbers from N to M except for: + - Every number divisible by 3: print "Fizz" + - Every number divisible by 5: print "Buzz" + - Numbers divisible by both 3 and 5: print "FizzBuzz" + + ## Input Format + Two space-separated integers: N and M + + ## Output Format + Print each result on a new line. + """, + constraints: "0 <= N < M <= 1000", + difficulty: "INTERMEDIATE", + visibility: "DRAFT", + tags: ["loops", "conditionals", "classic"], + solution: %{ + code: """ + n, m = [int(x) for x in input().split()] + for i in range(n, m+1): + fizz = i % 3 == 0 + buzz = i % 5 == 0 + print("Fizz" * fizz + "Buzz" * buzz + str(i) * (not fizz and not buzz)) + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: codincoder.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + # Add validators + validators = [ + %{ + input: "1 3", + output: "1\n2\nFizz" + }, + %{ + input: "3 5", + output: "Fizz\n4\nBuzz" + }, + %{ + input: "1 16", + output: "1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n16" + } + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'FizzBuzz' already exists") + existing + end + +# Reverse String puzzle +_reverse_puzzle = + case Repo.get_by(Puzzle, title: "Reverse String") do + nil -> + puzzle_attrs = %{ + title: "Reverse String", + statement: """ + Given a string, output it reversed. + + ## Input Format + A single line containing the string to reverse. + + ## Output Format + The reversed string. + """, + constraints: "1 <= string length <= 1000", + difficulty: "EASY", + visibility: "APPROVED", + tags: ["strings", "beginner"], + solution: %{ + code: "print(input()[::-1])", + language: "python", + languageVersion: "3.12.0" + }, + author_id: alice.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "hello", output: "olleh"}, + %{input: "world", output: "dlrow"}, + %{input: "racecar", output: "racecar"}, + %{input: "a", output: "a"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Reverse String' already exists") + existing + end + +# Sum of Numbers puzzle +_sum_puzzle = + case Repo.get_by(Puzzle, title: "Sum of Numbers") do + nil -> + puzzle_attrs = %{ + title: "Sum of Numbers", + statement: """ + Calculate the sum of all integers from 1 to N (inclusive). + + ## Input Format + A single integer N. + + ## Output Format + The sum of integers from 1 to N. + """, + constraints: "1 <= N <= 10000", + difficulty: "BEGINNER", + visibility: "APPROVED", + tags: ["math", "beginner", "loops"], + solution: %{ + code: """ + n = int(input()) + print(sum(range(1, n + 1))) + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: bob.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "1", output: "1"}, + %{input: "5", output: "15"}, + %{input: "10", output: "55"}, + %{input: "100", output: "5050"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Sum of Numbers' already exists") + existing + end + +# Palindrome Check puzzle +_palindrome_puzzle = + case Repo.get_by(Puzzle, title: "Palindrome Check") do + nil -> + puzzle_attrs = %{ + title: "Palindrome Check", + statement: """ + Determine if a given string is a palindrome. + + A palindrome reads the same forwards and backwards (ignoring case). + + ## Input Format + A single string. + + ## Output Format + Print "YES" if it's a palindrome, "NO" otherwise. + """, + constraints: "1 <= string length <= 1000", + difficulty: "EASY", + visibility: "APPROVED", + tags: ["strings", "palindrome"], + solution: %{ + code: """ + s = input().strip().lower() + print("YES" if s == s[::-1] else "NO") + """, + language: "python", + languageVersion: "3.12.0" + }, + author_id: alice.id + } + + {:ok, puzzle} = Puzzles.create_puzzle(puzzle_attrs) + + validators = [ + %{input: "racecar", output: "YES"}, + %{input: "hello", output: "NO"}, + %{input: "A man a plan a canal Panama", output: "NO"}, + %{input: "aabbaa", output: "YES"} + ] + + Enum.each(validators, fn validator_attrs -> + %PuzzleValidator{} + |> PuzzleValidator.changeset(Map.put(validator_attrs, :puzzle_id, puzzle.id)) + |> Repo.insert!() + end) + + puzzle + + existing -> + Logger.info("Puzzle 'Palindrome Check' already exists") + existing + end + +Logger.info("✅ Seed data created successfully!") +Logger.info(" Users: codincoder, alice, bob, moderator") +Logger.info(" Puzzles: 5 puzzles with validators") +Logger.info(" Programming Languages: 6 languages") diff --git a/libs/frontend/.prettierrc b/libs/frontend/.prettierrc index d72b66aa..e6559a74 100644 --- a/libs/frontend/.prettierrc +++ b/libs/frontend/.prettierrc @@ -3,6 +3,10 @@ "singleQuote": false, "trailingComma": "none", "printWidth": 80, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "plugins": [ + "prettier-plugin-organize-imports", + "prettier-plugin-svelte", + "prettier-plugin-tailwindcss" + ], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } diff --git a/libs/frontend/eslint.config.js b/libs/frontend/eslint.config.js index 6ad84a9d..011059a3 100644 --- a/libs/frontend/eslint.config.js +++ b/libs/frontend/eslint.config.js @@ -1,10 +1,10 @@ -import globals from "globals"; import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import svelte from "eslint-plugin-svelte"; import prettier from "eslint-config-prettier"; import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; import sortKeysFix from "eslint-plugin-sort-keys-fix"; +import svelte from "eslint-plugin-svelte"; +import globals from "globals"; +import tseslint from "typescript-eslint"; /** @type {import('eslint').Linter.Config[]} */ export default [ @@ -57,9 +57,9 @@ export default [ "sort-keys-fix": sortKeysFix }, rules: { - "@typescript-eslint/no-unused-vars": "warn", - "no-undef": "warn", - "no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": "error", + "no-undef": "error", + "no-unused-vars": "error", "sort-destructure-keys/sort-destructure-keys": [ 2, { caseSensitive: true } diff --git a/libs/frontend/knip.json b/libs/frontend/knip.json new file mode 100644 index 00000000..eda181b7 --- /dev/null +++ b/libs/frontend/knip.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "src/routes/**/*.{ts,svelte}", + "src/lib/**/*.{ts,svelte}", + "src/hooks.server.ts", + "svelte.config.js", + "vite.config.ts", + "orval.config.ts" + ], + "project": ["src/**/*.{ts,svelte}"], + "ignore": [ + "build/**", + ".svelte-kit/**", + "src/lib/api/generated/**", + "**/*.spec.ts", + "**/*.test.ts" + ], + "ignoreDependencies": ["types"] +} diff --git a/libs/frontend/orval.config.ts b/libs/frontend/orval.config.ts new file mode 100644 index 00000000..863f7b75 --- /dev/null +++ b/libs/frontend/orval.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "orval"; + +export default defineConfig({ + elixirApi: { + input: { + // Point to your Elixir backend OpenAPI spec + target: "../backend/codincod_api/priv/static/openapi.json" + }, + output: { + mode: "tags-split", + target: "./src/lib/api/generated/endpoints.ts", + schemas: "./src/lib/api/generated/schemas", + client: "fetch", // Use native fetch API + baseUrl: "", // Will be handled by custom mutator + mock: false, // Disabled: MSW mock generation has type issues with exactOptionalPropertyTypes + override: { + mutator: { + path: "./src/lib/api/custom-client.ts", + name: "customClient" + }, + fetch: { + includeHttpResponseReturnType: false // Return data directly, not { data, status } + } + } + }, + hooks: { + afterAllFilesWrite: "prettier --write" + } + } +}); diff --git a/libs/frontend/package.json b/libs/frontend/package.json index 1871be52..5a403af4 100644 --- a/libs/frontend/package.json +++ b/libs/frontend/package.json @@ -3,8 +3,8 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "vite dev", - "build": "vite build && node scripts/copy-maintenance.mjs", + "dev": "concurrently \"pnpm run generate:api:watch\" \"vite dev\"", + "build": "pnpm run generate:api && vite build && node scripts/copy-maintenance.mjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -12,7 +12,9 @@ "prettier": "npx prettier . --check", "prettier:fix": "npm run prettier -- --write", "lint": "prettier --check . && eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "generate:api": "orval", + "generate:api:watch": "orval --watch" }, "devDependencies": { "@eslint/js": "^9.7.0", @@ -29,6 +31,7 @@ "@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/parser": "^8.22.0", "bits-ui": "^2.14.1", + "concurrently": "^9.2.1", "eslint": "^9.10.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-sort-destructure-keys": "^2.0.0", @@ -37,9 +40,12 @@ "formsnap": "2.0.0-next.1", "globals": "^16.2.0", "mdsvex": "^0.12.3", + "openapi-typescript": "^7.10.1", + "orval": "^7.16.0", "paneforge": "1.0.0-next.5", "postcss": "^8.4.33", "prettier": "^3.3.3", + "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.7.1", "svelte": "^5.0.0", diff --git a/libs/frontend/scripts/copy-maintenance.mjs b/libs/frontend/scripts/copy-maintenance.mjs index 76deda09..6f00c9c8 100644 --- a/libs/frontend/scripts/copy-maintenance.mjs +++ b/libs/frontend/scripts/copy-maintenance.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { fileURLToPath } from "url"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/libs/frontend/src/lib/api/custom-client.ts b/libs/frontend/src/lib/api/custom-client.ts new file mode 100644 index 00000000..59f19e7c --- /dev/null +++ b/libs/frontend/src/lib/api/custom-client.ts @@ -0,0 +1,43 @@ +/** + * Custom Orval client that uses native fetch with cookie-based authentication + * This matches the signature expected by Orval's fetch client mode + * + * Supports server-side rendering by accepting custom fetch functions from SvelteKit + */ + +// Extend RequestInit to support custom fetch for server-side rendering +type CustomRequestInit = RequestInit & { + fetch?: typeof fetch; +}; + +export async function customClient( + url: string, + options?: CustomRequestInit +): Promise { + // Use custom fetch if provided in options, otherwise use global fetch + const fetchFn = options?.fetch || fetch; + + // Remove custom fetch from options to avoid passing it to native fetch + const { fetch: _, ...fetchOptions } = options || {}; + + // Make the request using fetch + const response = await fetchFn(url, { + ...fetchOptions, + credentials: "include" // Important for cookie-based auth + }); + + // Handle errors + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: response.statusText + })); + throw new Error(error.message || "API request failed"); + } + + // Handle 204 No Content + if (response.status === 204 || !response.body) { + return undefined as T; + } + + return response.json(); +} diff --git a/libs/frontend/src/lib/api/error-handler.ts b/libs/frontend/src/lib/api/error-handler.ts new file mode 100644 index 00000000..39bc0fcc --- /dev/null +++ b/libs/frontend/src/lib/api/error-handler.ts @@ -0,0 +1,201 @@ +/** + * Generic error handling utilities for API calls + * + * Provides consistent error handling patterns across the application + * with support for form errors, redirects, and user-friendly messages. + */ + +import type { ActionFailure } from "@sveltejs/kit"; +import { fail, redirect } from "@sveltejs/kit"; +import { ApiError } from "./errors"; + +export interface ErrorHandlerOptions { + /** Redirect to this URL on 401 unauthorized errors */ + redirectOnUnauthorized?: string; + /** Custom error messages for specific status codes */ + statusMessages?: Record; + /** Whether to return field errors from validation failures */ + includeFieldErrors?: boolean; + /** Default fallback message */ + defaultMessage?: string; +} + +export interface ApiErrorResult { + status: number; + message: string; + errors?: Record | undefined; + data?: unknown; +} + +/** + * Handle API errors consistently across the application + * + * @example + * ```ts + * try { + * await api.post('/api/login', credentials); + * } catch (error) { + * return handleApiError(error, { + * redirectOnUnauthorized: '/login', + * statusMessages: { + * 401: 'Invalid credentials', + * 429: 'Too many attempts. Please try again later.' + * } + * }); + * } + * ``` + */ +export function handleApiError( + error: unknown, + options: ErrorHandlerOptions = {} +): ActionFailure | never { + const { + redirectOnUnauthorized, + statusMessages = {}, + includeFieldErrors = true, + defaultMessage = "An unexpected error occurred" + } = options; + + // Handle redirect responses (from throw redirect()) + if (error instanceof Response) { + throw error; + } + + // Handle API errors + if (error instanceof ApiError) { + // Redirect on unauthorized if configured + if (error.status === 401 && redirectOnUnauthorized) { + throw redirect(302, redirectOnUnauthorized); + } + + // Get custom message for this status code + const message = + statusMessages[error.status] || error.data.message || error.message; + + // Extract field errors if requested + const fieldErrors = includeFieldErrors ? error.getFieldErrors() : undefined; + + return fail(error.status, { + status: error.status, + message, + errors: fieldErrors, + data: error.data + }); + } + + // Handle other errors + console.error("Unexpected error:", error); + return fail(500, { + status: 500, + message: defaultMessage + }); +} + +/** + * Wrap an async operation with standardized error handling + * + * @example + * ```ts + * export const actions = { + * submit: async ({ request, fetch }) => { + * return withErrorHandling(async () => { + * const api = createServerApi(fetch); + * const data = await request.formData(); + * return await api.post('/api/submit', { code: data.get('code') }); + * }, { + * redirectOnUnauthorized: '/login', + * defaultMessage: 'Failed to submit code' + * }); + * } + * }; + * ``` + */ +export async function withErrorHandling( + operation: () => Promise, + options: ErrorHandlerOptions = {} +): Promise> { + try { + return await operation(); + } catch (error) { + return handleApiError(error, options); + } +} + +/** + * Load data with error handling and optional fallback + * Useful for non-critical data that shouldn't break the page + * + * @example + * ```ts + * export async function load({ fetch }) { + * const api = createServerApi(fetch); + * + * const [puzzles, account] = await Promise.all([ + * api.get('/api/puzzles'), + * loadWithFallback(() => api.get('/api/account'), null) + * ]); + * + * return { puzzles, account }; // account is null if unauthenticated + * } + * ``` + */ +export async function loadWithFallback( + operation: () => Promise, + fallback: F +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof ApiError) { + console.warn("API call failed, using fallback:", error.message); + return fallback; + } + throw error; + } +} + +/** + * Check if user is authenticated, redirect if not + * + * @example + * ```ts + * export async function load({ fetch }) { + * const api = createServerApi(fetch); + * await requireAuth(() => api.get('/api/account'), '/login'); + * // ... rest of load function + * } + * ``` + */ +export async function requireAuth( + operation: () => Promise, + loginUrl = "/login" +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + throw redirect(302, loginUrl); + } + throw error; + } +} + +/** + * Get user-friendly error message from API error + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.data.message || error.message; + } + if (error instanceof Error) { + return error.message; + } + return "An unexpected error occurred"; +} + +/** + * Check if an error is a specific HTTP status + */ +export function isHttpError(error: unknown, status: number): boolean { + return error instanceof ApiError && error.status === status; +} diff --git a/libs/frontend/src/lib/api/errors.ts b/libs/frontend/src/lib/api/errors.ts new file mode 100644 index 00000000..0c09b0e2 --- /dev/null +++ b/libs/frontend/src/lib/api/errors.ts @@ -0,0 +1,117 @@ +/** + * API Error Types + * + * Custom error classes for handling API errors from the Elixir backend. + * These errors are thrown by the Orval-generated API client. + */ + +/** + * Error response structure from the Elixir backend + * Can have either array format or object/map format for errors + */ +export interface ElixirApiError { + message?: string; + error?: string; + // Array format: [{ field: "username", message: "error" }] + errors?: + | Array<{ + field?: string; + message: string; + index?: number; + }> + | Record; // Object/map format: { username: ["error1"], email: "error2" } + status?: number; +} + +/** + * Custom error class for API errors + * + * Wraps HTTP errors from the API with structured error data. + * This allows for consistent error handling across the application. + * + * @example + * ```ts + * try { + * await api.createPuzzle(data); + * } catch (error) { + * if (error instanceof ApiError) { + * console.error('API error:', error.status, error.data.message); + * if (error.isStatus(400)) { + * const fieldErrors = error.getFieldErrors(); + * console.error('Validation errors:', fieldErrors); + * } + * } + * } + * ``` + */ +export class ApiError extends Error { + constructor( + public status: number, + public data: ElixirApiError, + public response?: Response + ) { + super(data.message || data.error || `API error: ${status}`); + this.name = "ApiError"; + } + + /** + * Check if this is a specific HTTP error status + */ + isStatus(status: number): boolean { + return this.status === status; + } + + /** + * Check if this is a network/connection error + */ + isNetworkError(): boolean { + return this.status === 0 || this.status >= 500; + } + + /** + * Check if this is a client error (4xx) + */ + isClientError(): boolean { + return this.status >= 400 && this.status < 500; + } + + /** + * Get field-specific errors + * Handles both array format and object/map format from Elixir backend + */ + getFieldErrors(): Record { + if (!this.data.errors) return {}; + + // Handle object/map format: { username: ["error1", "error2"], email: ["error3"] } + if ( + typeof this.data.errors === "object" && + !Array.isArray(this.data.errors) + ) { + const fieldErrors: Record = {}; + for (const [field, messages] of Object.entries(this.data.errors)) { + if (Array.isArray(messages)) { + fieldErrors[field] = messages; + } else if (typeof messages === "string") { + fieldErrors[field] = [messages]; + } + } + return fieldErrors; + } + + // Handle array format: [{ field: "username", message: "error" }] + if (Array.isArray(this.data.errors)) { + const fieldErrors: Record = {}; + for (const error of this.data.errors) { + if (error.field) { + if (!fieldErrors[error.field]) { + fieldErrors[error.field] = []; + } + fieldErrors[error.field].push(error.message); + } + } + return fieldErrors; + } + + return {}; + } +} diff --git a/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts b/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts new file mode 100644 index 00000000..37ac3c43 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/account-preferences/account-preferences.ts @@ -0,0 +1,92 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PreferencesPayload } from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Delete preferences + */ +export const getCodincodApiWebAccountPreferenceControllerDeleteUrl = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerDelete = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerDeleteUrl(), + { + ...options, + method: "DELETE" + } + ); +}; + +/** + * @summary Get account preferences + */ +export const getCodincodApiWebAccountPreferenceControllerShowUrl = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Patch preferences + */ +export const getCodincodApiWebAccountPreferenceControllerPatchUrl = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerPatch = async ( + preferencesPayload?: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerPatchUrl(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; + +/** + * @summary Replace preferences + */ +export const getCodincodApiWebAccountPreferenceControllerReplaceUrl = () => { + return `/api/account/preferences`; +}; + +export const codincodApiWebAccountPreferenceControllerReplace = async ( + preferencesPayload: PreferencesPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountPreferenceControllerReplaceUrl(), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(preferencesPayload) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/account/account.ts b/libs/frontend/src/lib/api/generated/account/account.ts new file mode 100644 index 00000000..b11e69d1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/account/account.ts @@ -0,0 +1,95 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + AccountStatusResponse, + ProfileUpdateRequest, + ProfileUpdateResponse, + UserGamesResponse, + UserRankResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get current user's leaderboard ranking + */ +export const getCodincodApiWebAccountControllerLeaderboardRankUrl = () => { + return `/api/account/leaderboard`; +}; + +export const codincodApiWebAccountControllerLeaderboardRank = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerLeaderboardRankUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Current account status + */ +export const getCodincodApiWebAccountControllerShowUrl = () => { + return `/api/account`; +}; + +export const codincodApiWebAccountControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get games for current user + */ +export const getCodincodApiWebAccountControllerGamesUrl = () => { + return `/api/account/games`; +}; + +export const codincodApiWebAccountControllerGames = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerGamesUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Update profile + */ +export const getCodincodApiWebAccountControllerUpdateProfileUrl = () => { + return `/api/account/profile`; +}; + +export const codincodApiWebAccountControllerUpdateProfile = async ( + profileUpdateRequest?: ProfileUpdateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAccountControllerUpdateProfileUrl(), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(profileUpdateRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/auth/auth.ts b/libs/frontend/src/lib/api/generated/auth/auth.ts new file mode 100644 index 00000000..444a060c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/auth/auth.ts @@ -0,0 +1,96 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + LoginRequest, + MessageResponse, + RegisterRequest +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Authenticate user + */ +export const getCodincodApiWebAuthControllerLoginUrl = () => { + return `/api/login`; +}; + +export const codincodApiWebAuthControllerLogin = async ( + loginRequest: LoginRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLoginUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(loginRequest) + } + ); +}; + +/** + * @summary Register new user + */ +export const getCodincodApiWebAuthControllerRegisterUrl = () => { + return `/api/register`; +}; + +export const codincodApiWebAuthControllerRegister = async ( + registerRequest: RegisterRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRegisterUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(registerRequest) + } + ); +}; + +/** + * @summary Logout current user + */ +export const getCodincodApiWebAuthControllerLogoutUrl = () => { + return `/api/logout`; +}; + +export const codincodApiWebAuthControllerLogout = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerLogoutUrl(), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Refresh authentication token + */ +export const getCodincodApiWebAuthControllerRefreshUrl = () => { + return `/api/refresh`; +}; + +export const codincodApiWebAuthControllerRefresh = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebAuthControllerRefreshUrl(), + { + ...options, + method: "POST" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/default/default.ts b/libs/frontend/src/lib/api/generated/default/default.ts new file mode 100644 index 00000000..a95e8941 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/default/default.ts @@ -0,0 +1,118 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebProgrammingLanguageControllerIndex200Item, + CommentResponse, + CreateRequest, + VoteRequest +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Create a comment on a puzzle + */ +export const getCodincodApiWebPuzzleCommentControllerCreateUrl = ( + id: string +) => { + return `/api/puzzle/${id}/comment`; +}; + +export const codincodApiWebPuzzleCommentControllerCreate = async ( + id: string, + createRequest?: CreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleCommentControllerCreateUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createRequest) + } + ); +}; + +/** + * @summary Vote on a comment + */ +export const getCodincodApiWebCommentControllerVoteUrl = (id: string) => { + return `/api/comment/${id}/vote`; +}; + +export const codincodApiWebCommentControllerVote = async ( + id: string, + voteRequest?: VoteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerVoteUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(voteRequest) + } + ); +}; + +/** + * @summary Delete a comment + */ +export const getCodincodApiWebCommentControllerDeleteUrl = (id: string) => { + return `/api/comment/${id}`; +}; + +export const codincodApiWebCommentControllerDelete = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebCommentControllerDeleteUrl(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * @summary Get comment by ID + */ +export const getCodincodApiWebCommentControllerShowUrl = (id: string) => { + return `/api/comment/${id}`; +}; + +export const codincodApiWebCommentControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebCommentControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List all programming languages + */ +export const getCodincodApiWebProgrammingLanguageControllerIndexUrl = () => { + return `/api/programming-languages`; +}; + +export const codincodApiWebProgrammingLanguageControllerIndex = async ( + options?: RequestInit +): Promise => { + return customClient< + CodincodApiWebProgrammingLanguageControllerIndex200Item[] + >(getCodincodApiWebProgrammingLanguageControllerIndexUrl(), { + ...options, + method: "GET" + }); +}; diff --git a/libs/frontend/src/lib/api/generated/execute/execute.ts b/libs/frontend/src/lib/api/generated/execute/execute.ts new file mode 100644 index 00000000..77504a20 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/execute/execute.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteRequest, ExecuteResponse } from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Runs code against Piston with custom test input/output for validation + * @summary Execute code without saving + */ +export const getCodincodApiWebExecuteControllerCreateUrl = () => { + return `/api/execute`; +}; + +export const codincodApiWebExecuteControllerCreate = async ( + executeRequest?: ExecuteRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebExecuteControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(executeRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/games/games.ts b/libs/frontend/src/lib/api/generated/games/games.ts new file mode 100644 index 00000000..1b97e0f5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/games/games.ts @@ -0,0 +1,162 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CreateGameRequest, + GameResponse, + GameSubmitCodeRequest, + LeaveGameResponse, + SubmitCodeResponse, + WaitingRoomsResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Join a game lobby + */ +export const getCodincodApiWebGameControllerJoinUrl = (id: string) => { + return `/api/games/${id}/join`; +}; + +export const codincodApiWebGameControllerJoin = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerJoinUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary List all waiting game lobbies + */ +export const getCodincodApiWebGameControllerListWaitingRoomsUrl = () => { + return `/api/games/waiting`; +}; + +export const codincodApiWebGameControllerListWaitingRooms = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerListWaitingRoomsUrl(), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Start a game (host only) + */ +export const getCodincodApiWebGameControllerStartUrl = (id: string) => { + return `/api/games/${id}/start`; +}; + +export const codincodApiWebGameControllerStart = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerStartUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Get game details + */ +export const getCodincodApiWebGameControllerShowUrl = (id: string) => { + return `/api/games/${id}`; +}; + +export const codincodApiWebGameControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Links an existing submission to a game, marking it as a player's game submission. + * @summary Submit code for a game + */ +export const getCodincodApiWebGameControllerSubmitCodeUrl = (id: string) => { + return `/api/games/${id}/submit`; +}; + +export const codincodApiWebGameControllerSubmitCode = async ( + id: string, + gameSubmitCodeRequest?: GameSubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerSubmitCodeUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(gameSubmitCodeRequest) + } + ); +}; + +/** + * @summary Leave a game lobby + */ +export const getCodincodApiWebGameControllerLeaveUrl = (id: string) => { + return `/api/games/${id}/leave`; +}; + +export const codincodApiWebGameControllerLeave = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerLeaveUrl(id), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary Create a new game lobby + */ +export const getCodincodApiWebGameControllerCreateUrl = () => { + return `/api/games`; +}; + +export const codincodApiWebGameControllerCreate = async ( + createGameRequest?: CreateGameRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebGameControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createGameRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/health/health.ts b/libs/frontend/src/lib/api/generated/health/health.ts new file mode 100644 index 00000000..d78fbf1d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/health/health.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebHealthControllerShow200 } from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Returns service health status + * @summary Health check + */ +export const getCodincodApiWebHealthControllerShowUrl = () => { + return `/api/health`; +}; + +export const codincodApiWebHealthControllerShow = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebHealthControllerShowUrl(), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/index.ts b/libs/frontend/src/lib/api/generated/index.ts new file mode 100644 index 00000000..8e30e81d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/index.ts @@ -0,0 +1,46 @@ +/** + * Barrel export for all generated API endpoints + * Import from here for convenience: import { codincodApiWebPuzzleControllerIndex, ... } from '$lib/api/generated' + */ + +// Account endpoints +export * from "./account/account"; + +// Account preferences endpoints +export * from "./account-preferences/account-preferences"; + +// Auth endpoints +export * from "./auth/auth"; + +// Execute endpoints +export * from "./execute/execute"; + +// Game endpoints +export * from "./games/games"; + +// Health endpoints +export * from "./health/health"; + +// Leaderboard endpoints +export * from "./leaderboard/leaderboard"; + +// Metrics endpoints +export * from "./metrics/metrics"; + +// Moderation endpoints +export * from "./moderation/moderation"; + +// Password reset endpoints +export * from "./password-reset/password-reset"; + +// Puzzle endpoints +export * from "./puzzle/puzzle"; + +// Submission endpoints +export * from "./submission/submission"; + +// User endpoints +export * from "./user/user"; + +// All schemas/types +export * from "./schemas"; diff --git a/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts b/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts new file mode 100644 index 00000000..47eb4273 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/leaderboard/leaderboard.ts @@ -0,0 +1,85 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebLeaderboardControllerGlobalParams, + CodincodApiWebLeaderboardControllerPuzzleParams, + GlobalLeaderboardResponse, + PuzzleLeaderboardResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get puzzle-specific leaderboard + */ +export const getCodincodApiWebLeaderboardControllerPuzzleUrl = ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzleParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/leaderboard/puzzle/${puzzleId}?${stringifiedParams}` + : `/api/leaderboard/puzzle/${puzzleId}`; +}; + +export const codincodApiWebLeaderboardControllerPuzzle = async ( + puzzleId: string, + params?: CodincodApiWebLeaderboardControllerPuzzleParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerPuzzleUrl(puzzleId, params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get global leaderboard rankings + */ +export const getCodincodApiWebLeaderboardControllerGlobalUrl = ( + params?: CodincodApiWebLeaderboardControllerGlobalParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/leaderboard/global?${stringifiedParams}` + : `/api/leaderboard/global`; +}; + +export const codincodApiWebLeaderboardControllerGlobal = async ( + params?: CodincodApiWebLeaderboardControllerGlobalParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebLeaderboardControllerGlobalUrl(params), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/metrics/metrics.ts b/libs/frontend/src/lib/api/generated/metrics/metrics.ts new file mode 100644 index 00000000..dc14d309 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/metrics/metrics.ts @@ -0,0 +1,77 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + PlatformMetricsResponse, + PuzzleStatsResponse, + UserStatsResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get detailed statistics for a puzzle + */ +export const getCodincodApiWebMetricsControllerPuzzleStatsUrl = ( + puzzleId: string +) => { + return `/api/metrics/puzzle/${puzzleId}`; +}; + +export const codincodApiWebMetricsControllerPuzzleStats = async ( + puzzleId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPuzzleStatsUrl(puzzleId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get detailed statistics for a user + */ +export const getCodincodApiWebMetricsControllerUserStatsUrl = ( + userId: string +) => { + return `/api/metrics/user/${userId}`; +}; + +export const codincodApiWebMetricsControllerUserStats = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerUserStatsUrl(userId), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get platform-wide statistics + */ +export const getCodincodApiWebMetricsControllerPlatformUrl = () => { + return `/api/metrics/platform`; +}; + +export const codincodApiWebMetricsControllerPlatform = async ( + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebMetricsControllerPlatformUrl(), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/moderation/moderation.ts b/libs/frontend/src/lib/api/generated/moderation/moderation.ts new file mode 100644 index 00000000..933a9b8b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/moderation/moderation.ts @@ -0,0 +1,209 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + BanResponse, + BanUserRequest, + CodincodApiWebModerationControllerListReportsParams, + CodincodApiWebModerationControllerListReviewsParams, + CreateReportRequest, + ReportResponse, + ReportsListResponse, + ResolveReportRequest, + ReviewDecisionRequest, + ReviewResponse, + ReviewsListResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Unban a user (admin only) + */ +export const getCodincodApiWebModerationControllerUnbanUserUrl = ( + userId: string +) => { + return `/api/moderation/user/${userId}/unban`; +}; + +export const codincodApiWebModerationControllerUnbanUser = async ( + userId: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerUnbanUserUrl(userId), + { + ...options, + method: "POST" + } + ); +}; + +/** + * @summary List reports (admin only) + */ +export const getCodincodApiWebModerationControllerListReportsUrl = ( + params?: CodincodApiWebModerationControllerListReportsParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/moderation/reports?${stringifiedParams}` + : `/api/moderation/reports`; +}; + +export const codincodApiWebModerationControllerListReports = async ( + params?: CodincodApiWebModerationControllerListReportsParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReportsUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Ban a user (admin only) + */ +export const getCodincodApiWebModerationControllerBanUserUrl = ( + userId: string +) => { + return `/api/moderation/user/${userId}/ban`; +}; + +export const codincodApiWebModerationControllerBanUser = async ( + userId: string, + banUserRequest?: BanUserRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerBanUserUrl(userId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(banUserRequest) + } + ); +}; + +/** + * @summary List pending moderation reviews (moderator only) + */ +export const getCodincodApiWebModerationControllerListReviewsUrl = ( + params?: CodincodApiWebModerationControllerListReviewsParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/moderation/reviews?${stringifiedParams}` + : `/api/moderation/reviews`; +}; + +export const codincodApiWebModerationControllerListReviews = async ( + params?: CodincodApiWebModerationControllerListReviewsParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerListReviewsUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Review and approve/reject content (moderator only) + */ +export const getCodincodApiWebModerationControllerReviewContentUrl = ( + id: string +) => { + return `/api/moderation/review/${id}`; +}; + +export const codincodApiWebModerationControllerReviewContent = async ( + id: string, + reviewDecisionRequest?: ReviewDecisionRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerReviewContentUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(reviewDecisionRequest) + } + ); +}; + +/** + * @summary Create a new report for inappropriate content + */ +export const getCodincodApiWebModerationControllerCreateReportUrl = () => { + return `/api/moderation/report`; +}; + +export const codincodApiWebModerationControllerCreateReport = async ( + createReportRequest?: CreateReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerCreateReportUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createReportRequest) + } + ); +}; + +/** + * @summary Resolve a report (admin only) + */ +export const getCodincodApiWebModerationControllerResolveReportUrl = ( + id: string +) => { + return `/api/moderation/report/${id}/resolve`; +}; + +export const codincodApiWebModerationControllerResolveReport = async ( + id: string, + resolveReportRequest?: ResolveReportRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebModerationControllerResolveReportUrl(id), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resolveReportRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts b/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts new file mode 100644 index 00000000..566ab235 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/password-reset/password-reset.ts @@ -0,0 +1,61 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + RequestPayload, + RequestResponse, + ResetPayload, + ResetResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Sends password reset email if user exists + * @summary Request password reset + */ +export const getCodincodApiWebPasswordResetControllerRequestResetUrl = () => { + return `/api/password-reset/request`; +}; + +export const codincodApiWebPasswordResetControllerRequestReset = async ( + requestPayload?: RequestPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerRequestResetUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(requestPayload) + } + ); +}; + +/** + * Validates token and updates user password + * @summary Reset password with token + */ +export const getCodincodApiWebPasswordResetControllerResetPasswordUrl = () => { + return `/api/password-reset/reset`; +}; + +export const codincodApiWebPasswordResetControllerResetPassword = async ( + resetPayload?: ResetPayload, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPasswordResetControllerResetPasswordUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(resetPayload) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts b/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts new file mode 100644 index 00000000..f9b7bd8e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/puzzle/puzzle.ts @@ -0,0 +1,156 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + CodincodApiWebPuzzleControllerIndexParams, + PaginatedListResponse, + PuzzleCreateRequest, + PuzzleResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * Returns puzzle with full solution details. Only available to puzzle author or admins. + * @summary Get puzzle solution for editing + */ +export const getCodincodApiWebPuzzleControllerSolutionUrl = (id: string) => { + return `/api/puzzle/${id}/solution`; +}; + +export const codincodApiWebPuzzleControllerSolution = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerSolutionUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Deletes a puzzle. Only available to puzzle author or admins. + * @summary Delete puzzle + */ +export const getCodincodApiWebPuzzleControllerDeleteUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerDelete = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient(getCodincodApiWebPuzzleControllerDeleteUrl(id), { + ...options, + method: "DELETE" + }); +}; + +/** + * Returns a single puzzle by ID (public view, no solution details). + * @summary Get puzzle by ID + */ +export const getCodincodApiWebPuzzleControllerShowUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * Updates an existing puzzle. Only available to puzzle author or admins. + * @summary Update puzzle + */ +export const getCodincodApiWebPuzzleControllerUpdateUrl = (id: string) => { + return `/api/puzzle/${id}`; +}; + +export const codincodApiWebPuzzleControllerUpdate = async ( + id: string, + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerUpdateUrl(id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; + +/** + * Returns paginated puzzles matching the legacy Fastify `/puzzle` listing response. + * @summary List puzzles + */ +export const getCodincodApiWebPuzzleControllerIndexUrl = ( + params?: CodincodApiWebPuzzleControllerIndexParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/puzzles?${stringifiedParams}` + : `/api/puzzles`; +}; + +export const codincodApiWebPuzzleControllerIndex = async ( + params?: CodincodApiWebPuzzleControllerIndexParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerIndexUrl(params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Create puzzle + */ +export const getCodincodApiWebPuzzleControllerCreateUrl = () => { + return `/api/puzzles`; +}; + +export const codincodApiWebPuzzleControllerCreate = async ( + puzzleCreateRequest?: PuzzleCreateRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebPuzzleControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(puzzleCreateRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts new file mode 100644 index 00000000..c9d35907 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferences.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AccountPreferencesEditor } from "./accountPreferencesEditor"; +import type { AccountPreferencesTheme } from "./accountPreferencesTheme"; + +export interface AccountPreferences { + blockedUsers?: string[]; + editor?: AccountPreferencesEditor; + /** @nullable */ + preferredLanguage?: string | null; + /** @nullable */ + theme?: AccountPreferencesTheme; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts new file mode 100644 index 00000000..4833553d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesEditor.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AccountPreferencesEditor = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts new file mode 100644 index 00000000..259ff8c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountPreferencesTheme.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type AccountPreferencesTheme = + | (typeof AccountPreferencesTheme)[keyof typeof AccountPreferencesTheme] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const AccountPreferencesTheme = { + dark: "dark", + light: "light" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts new file mode 100644 index 00000000..35efb723 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AccountProfileUpdateRequest { + /** @maxLength 500 */ + bio?: string; + /** @maxLength 100 */ + location?: string; + picture?: string; + /** @maxItems 5 */ + socials?: string[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts new file mode 100644 index 00000000..64151260 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AccountProfileUpdateResponseProfile } from "./accountProfileUpdateResponseProfile"; + +export interface AccountProfileUpdateResponse { + message?: string; + profile?: AccountProfileUpdateResponseProfile; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts new file mode 100644 index 00000000..50ab3f7f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountProfileUpdateResponseProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AccountProfileUpdateResponseProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts b/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts new file mode 100644 index 00000000..dfae687a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/accountStatusResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AccountStatusResponse { + isAuthenticated: boolean; + role?: string; + userId?: string; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts new file mode 100644 index 00000000..36c9f675 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivity } from "./activityResponseActivity"; +import type { ActivityResponseUser } from "./activityResponseUser"; + +export interface ActivityResponse { + activity?: ActivityResponseActivity; + message?: string; + user?: ActivityResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts new file mode 100644 index 00000000..4cc3e639 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivity.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItem } from "./activityResponseActivityPuzzlesItem"; +import type { ActivityResponseActivitySubmissionsItem } from "./activityResponseActivitySubmissionsItem"; + +export type ActivityResponseActivity = { + puzzles?: ActivityResponseActivityPuzzlesItem[]; + submissions?: ActivityResponseActivitySubmissionsItem[]; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts new file mode 100644 index 00000000..23f50f85 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItemAuthor } from "./activityResponseActivityPuzzlesItemAuthor"; +import type { ActivityResponseActivityPuzzlesItemSolution } from "./activityResponseActivityPuzzlesItemSolution"; +import type { ActivityResponseActivityPuzzlesItemValidatorsItem } from "./activityResponseActivityPuzzlesItemValidatorsItem"; + +export type ActivityResponseActivityPuzzlesItem = { + _id?: string; + author?: ActivityResponseActivityPuzzlesItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: ActivityResponseActivityPuzzlesItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: ActivityResponseActivityPuzzlesItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts new file mode 100644 index 00000000..f2e6ea57 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivityPuzzlesItemAuthorProfile } from "./activityResponseActivityPuzzlesItemAuthorProfile"; + +export type ActivityResponseActivityPuzzlesItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: ActivityResponseActivityPuzzlesItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts new file mode 100644 index 00000000..cf5a8fb3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts new file mode 100644 index 00000000..c8d83198 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts new file mode 100644 index 00000000..4e7563a4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivityPuzzlesItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivityPuzzlesItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts new file mode 100644 index 00000000..99a958eb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivitySubmissionsItemProgrammingLanguage } from "./activityResponseActivitySubmissionsItemProgrammingLanguage"; +import type { ActivityResponseActivitySubmissionsItemPuzzle } from "./activityResponseActivitySubmissionsItemPuzzle"; +import type { ActivityResponseActivitySubmissionsItemResult } from "./activityResponseActivitySubmissionsItemResult"; +import type { ActivityResponseActivitySubmissionsItemUser } from "./activityResponseActivitySubmissionsItemUser"; + +export type ActivityResponseActivitySubmissionsItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: ActivityResponseActivitySubmissionsItemProgrammingLanguage; + puzzle?: ActivityResponseActivitySubmissionsItemPuzzle; + result?: ActivityResponseActivitySubmissionsItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: ActivityResponseActivitySubmissionsItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts new file mode 100644 index 00000000..c4bce4e5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts new file mode 100644 index 00000000..bb6feb52 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts new file mode 100644 index 00000000..32f93e77 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemResult.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemResult = { + [key: string]: unknown; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts new file mode 100644 index 00000000..1a0fd4f0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseActivitySubmissionsItemUserProfile } from "./activityResponseActivitySubmissionsItemUserProfile"; + +export type ActivityResponseActivitySubmissionsItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ActivityResponseActivitySubmissionsItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts new file mode 100644 index 00000000..0a9ff4dc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseActivitySubmissionsItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseActivitySubmissionsItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts new file mode 100644 index 00000000..e0c6e6df --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityResponseUserProfile } from "./activityResponseUserProfile"; + +export type ActivityResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ActivityResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts new file mode 100644 index 00000000..789bdf62 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/activityResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ActivityResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts b/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts new file mode 100644 index 00000000..05cac9b9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/authMessageResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AuthMessageResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/author.ts b/libs/frontend/src/lib/api/generated/schemas/author.ts new file mode 100644 index 00000000..0819e67e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/author.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { AuthorProfile } from "./authorProfile"; + +export interface Author { + _id?: string; + createdAt?: string; + id?: string; + profile?: AuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts new file mode 100644 index 00000000..7d8a37f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/authorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type AuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts new file mode 100644 index 00000000..bf17d777 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/availabilityResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface AvailabilityResponse { + available?: boolean; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/banResponse.ts b/libs/frontend/src/lib/api/generated/schemas/banResponse.ts new file mode 100644 index 00000000..b6e4f5b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/banResponse.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface BanResponse { + banned?: boolean; + /** @nullable */ + bannedUntil?: string | null; + /** @nullable */ + reason?: string | null; + userId?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts b/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts new file mode 100644 index 00000000..af8c29ab --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/banUserRequest.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface BanUserRequest { + /** @nullable */ + bannedUntil?: string | null; + /** @nullable */ + durationDays?: number | null; + /** @nullable */ + reason?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts new file mode 100644 index 00000000..c29d0447 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebHealthControllerShow200 = { + status?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts new file mode 100644 index 00000000..2cc5139a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebHealthControllerShow2200.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebHealthControllerShow2200 = { + status?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts new file mode 100644 index 00000000..b42a5e0f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2GameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerGlobal2GameMode = + (typeof CodincodApiWebLeaderboardControllerGlobal2GameMode)[keyof typeof CodincodApiWebLeaderboardControllerGlobal2GameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebLeaderboardControllerGlobal2GameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts new file mode 100644 index 00000000..76030409 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobal2Params.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebLeaderboardControllerGlobal2GameMode } from "./codincodApiWebLeaderboardControllerGlobal2GameMode"; + +export type CodincodApiWebLeaderboardControllerGlobal2Params = { + /** + * Game mode filter + */ + game_mode?: CodincodApiWebLeaderboardControllerGlobal2GameMode; + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; + /** + * Pagination offset + * @minimum 0 + */ + offset?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts new file mode 100644 index 00000000..ae1b43ec --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalGameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerGlobalGameMode = + (typeof CodincodApiWebLeaderboardControllerGlobalGameMode)[keyof typeof CodincodApiWebLeaderboardControllerGlobalGameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebLeaderboardControllerGlobalGameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts new file mode 100644 index 00000000..8fe30600 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerGlobalParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebLeaderboardControllerGlobalGameMode } from "./codincodApiWebLeaderboardControllerGlobalGameMode"; + +export type CodincodApiWebLeaderboardControllerGlobalParams = { + /** + * Game mode filter + */ + game_mode?: CodincodApiWebLeaderboardControllerGlobalGameMode; + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; + /** + * Pagination offset + * @minimum 0 + */ + offset?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts new file mode 100644 index 00000000..5c6a8587 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzle2Params.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerPuzzle2Params = { + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts new file mode 100644 index 00000000..60decddc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebLeaderboardControllerPuzzleParams.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebLeaderboardControllerPuzzleParams = { + /** + * Number of entries to return (1-100) + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts new file mode 100644 index 00000000..4d6300fa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Params.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReports2ProblemType } from "./codincodApiWebModerationControllerListReports2ProblemType"; +import type { CodincodApiWebModerationControllerListReports2Status } from "./codincodApiWebModerationControllerListReports2Status"; + +export type CodincodApiWebModerationControllerListReports2Params = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReports2Status; + /** + * Filter by problem type + */ + problem_type?: CodincodApiWebModerationControllerListReports2ProblemType; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts new file mode 100644 index 00000000..74a7141e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2ProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReports2ProblemType = + (typeof CodincodApiWebModerationControllerListReports2ProblemType)[keyof typeof CodincodApiWebModerationControllerListReports2ProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReports2ProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts new file mode 100644 index 00000000..b2f21a1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReports2Status.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReports2Status = + (typeof CodincodApiWebModerationControllerListReports2Status)[keyof typeof CodincodApiWebModerationControllerListReports2Status]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReports2Status = { + pending: "pending", + reviewing: "reviewing", + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts new file mode 100644 index 00000000..eb3c1b4d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsParams.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReportsProblemType } from "./codincodApiWebModerationControllerListReportsProblemType"; +import type { CodincodApiWebModerationControllerListReportsStatus } from "./codincodApiWebModerationControllerListReportsStatus"; + +export type CodincodApiWebModerationControllerListReportsParams = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReportsStatus; + /** + * Filter by problem type + */ + problem_type?: CodincodApiWebModerationControllerListReportsProblemType; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts new file mode 100644 index 00000000..72aad3f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReportsProblemType = + (typeof CodincodApiWebModerationControllerListReportsProblemType)[keyof typeof CodincodApiWebModerationControllerListReportsProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReportsProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts new file mode 100644 index 00000000..eba8678a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReportsStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReportsStatus = + (typeof CodincodApiWebModerationControllerListReportsStatus)[keyof typeof CodincodApiWebModerationControllerListReportsStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReportsStatus = { + pending: "pending", + reviewing: "reviewing", + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts new file mode 100644 index 00000000..ef953ac0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Params.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReviews2Status } from "./codincodApiWebModerationControllerListReviews2Status"; + +export type CodincodApiWebModerationControllerListReviews2Params = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReviews2Status; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts new file mode 100644 index 00000000..16b36dfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviews2Status.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReviews2Status = + (typeof CodincodApiWebModerationControllerListReviews2Status)[keyof typeof CodincodApiWebModerationControllerListReviews2Status]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReviews2Status = { + pending: "pending", + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts new file mode 100644 index 00000000..f21dfce7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsParams.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CodincodApiWebModerationControllerListReviewsStatus } from "./codincodApiWebModerationControllerListReviewsStatus"; + +export type CodincodApiWebModerationControllerListReviewsParams = { + /** + * Filter by status + */ + status?: CodincodApiWebModerationControllerListReviewsStatus; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts new file mode 100644 index 00000000..4d97af50 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebModerationControllerListReviewsStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebModerationControllerListReviewsStatus = + (typeof CodincodApiWebModerationControllerListReviewsStatus)[keyof typeof CodincodApiWebModerationControllerListReviewsStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CodincodApiWebModerationControllerListReviewsStatus = { + pending: "pending", + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts new file mode 100644 index 00000000..d8b36426 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex200Item.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebProgrammingLanguageControllerIndex200Item = { + aliases?: string[]; + id?: string; + isActive?: boolean; + language?: string; + runtime?: string; + version?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts new file mode 100644 index 00000000..50803dde --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebProgrammingLanguageControllerIndex2200Item.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebProgrammingLanguageControllerIndex2200Item = { + aliases?: string[]; + id?: string; + isActive?: boolean; + language?: string; + runtime?: string; + version?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts new file mode 100644 index 00000000..bbcc07ae --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndex2Params.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebPuzzleControllerIndex2Params = { + /** + * Page number + * @minimum 1 + */ + page?: number; + /** + * Number of puzzles per page + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts new file mode 100644 index 00000000..9e6b6ca1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebPuzzleControllerIndexParams.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebPuzzleControllerIndexParams = { + /** + * Page number + * @minimum 1 + */ + page?: number; + /** + * Number of puzzles per page + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts new file mode 100644 index 00000000..cfa075c1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzles2Params.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebUserControllerPuzzles2Params = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts new file mode 100644 index 00000000..2a4d8afd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/codincodApiWebUserControllerPuzzlesParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CodincodApiWebUserControllerPuzzlesParams = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts new file mode 100644 index 00000000..666c8721 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentCreateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface CommentCreateRequest { + /** @nullable */ + replyOn?: string | null; + /** + * @minLength 1 + * @maxLength 320 + */ + text: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts new file mode 100644 index 00000000..18ba4701 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponse.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CommentResponseAuthor } from "./commentResponseAuthor"; +import type { CommentResponseCommentType } from "./commentResponseCommentType"; + +export interface CommentResponse { + author?: CommentResponseAuthor; + authorId: string; + body: string; + commentType: CommentResponseCommentType; + downvote?: number; + id: string; + insertedAt?: string; + /** @nullable */ + parentCommentId?: string | null; + /** @nullable */ + puzzleId?: string | null; + updatedAt?: string; + upvote?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts new file mode 100644 index 00000000..ffdedfe9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponseAuthor.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentResponseAuthor = { + id?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts b/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts new file mode 100644 index 00000000..00ec99c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentResponseCommentType.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentResponseCommentType = + (typeof CommentResponseCommentType)[keyof typeof CommentResponseCommentType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CommentResponseCommentType = { + "puzzle-comment": "puzzle-comment", + "comment-comment": "comment-comment", + "submission-comment": "submission-comment" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts new file mode 100644 index 00000000..b4612dd1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CommentVoteRequestType } from "./commentVoteRequestType"; + +export interface CommentVoteRequest { + type: CommentVoteRequestType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts new file mode 100644 index 00000000..61c068c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/commentVoteRequestType.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CommentVoteRequestType = + (typeof CommentVoteRequestType)[keyof typeof CommentVoteRequestType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CommentVoteRequestType = { + upvote: "upvote", + downvote: "downvote" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts new file mode 100644 index 00000000..81befc5e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createGameRequest.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CreateGameRequestGameMode } from "./createGameRequestGameMode"; + +export interface CreateGameRequest { + gameMode?: CreateGameRequestGameMode; + /** + * @minimum 2 + * @maximum 10 + */ + maxPlayers?: number; + puzzleId: string; + /** @nullable */ + timeLimit?: number | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts b/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts new file mode 100644 index 00000000..6f35bb1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createGameRequestGameMode.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateGameRequestGameMode = + (typeof CreateGameRequestGameMode)[keyof typeof CreateGameRequestGameMode]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateGameRequestGameMode = { + standard: "standard", + timed: "timed", + ranked: "ranked" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts new file mode 100644 index 00000000..323452f4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { CreateReportRequestContentType } from "./createReportRequestContentType"; +import type { CreateReportRequestProblemType } from "./createReportRequestProblemType"; + +export interface CreateReportRequest { + contentId: string; + contentType: CreateReportRequestContentType; + /** @nullable */ + description?: string | null; + problemType: CreateReportRequestProblemType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts new file mode 100644 index 00000000..0c27ee73 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequestContentType.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateReportRequestContentType = + (typeof CreateReportRequestContentType)[keyof typeof CreateReportRequestContentType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateReportRequestContentType = { + puzzle: "puzzle", + comment: "comment", + submission: "submission", + user: "user" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts b/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts new file mode 100644 index 00000000..5b802521 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createReportRequestProblemType.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateReportRequestProblemType = + (typeof CreateReportRequestProblemType)[keyof typeof CreateReportRequestProblemType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateReportRequestProblemType = { + spam: "spam", + inappropriate: "inappropriate", + copyright: "copyright", + harassment: "harassment", + other: "other" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequest.ts b/libs/frontend/src/lib/api/generated/schemas/createRequest.ts new file mode 100644 index 00000000..bb9b168e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface CreateRequest { + /** @nullable */ + replyOn?: string | null; + /** + * @minLength 1 + * @maxLength 320 + */ + text: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts b/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts new file mode 100644 index 00000000..48d11b13 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequestDifficulty.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type CreateRequestDifficulty = + | (typeof CreateRequestDifficulty)[keyof typeof CreateRequestDifficulty] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const CreateRequestDifficulty = { + easy: "easy", + medium: "medium", + hard: "hard", + beginner: "beginner", + intermediate: "intermediate", + advanced: "advanced", + expert: "expert" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts new file mode 100644 index 00000000..96970cbf --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/createRequestValidatorsItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type CreateRequestValidatorsItem = { + input: string; + isPublic?: boolean; + output: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts b/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts new file mode 100644 index 00000000..ac5c8078 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/errorResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorResponseErrors } from "./errorResponseErrors"; + +export interface ErrorResponse { + error?: string; + errors?: ErrorResponseErrors; + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts b/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts new file mode 100644 index 00000000..ac7bc7d7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/errorResponseErrors.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ErrorResponseErrors = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts new file mode 100644 index 00000000..49e0c5c0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeRequest.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ExecuteRequest { + /** @minLength 1 */ + code: string; + /** @minLength 1 */ + language: string; + testInput?: string; + testOutput?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts new file mode 100644 index 00000000..2ab5b975 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteResponseCompile } from "./executeResponseCompile"; +import type { ExecuteResponsePuzzleResultInformation } from "./executeResponsePuzzleResultInformation"; +import type { ExecuteResponseRun } from "./executeResponseRun"; + +export interface ExecuteResponse { + /** @nullable */ + compile?: ExecuteResponseCompile; + puzzleResultInformation?: ExecuteResponsePuzzleResultInformation; + run?: ExecuteResponseRun; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts new file mode 100644 index 00000000..114af757 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponseCompile.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ExecuteResponseCompile = { [key: string]: unknown } | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts new file mode 100644 index 00000000..7d12a151 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformation.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ExecuteResponsePuzzleResultInformationResult } from "./executeResponsePuzzleResultInformationResult"; + +export type ExecuteResponsePuzzleResultInformation = { + /** @minimum 0 */ + failed?: number; + /** @minimum 0 */ + passed?: number; + result?: ExecuteResponsePuzzleResultInformationResult; + /** + * @minimum 0 + * @maximum 1 + */ + successRate?: number; + /** @minimum 1 */ + total?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts new file mode 100644 index 00000000..a54ff38a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponsePuzzleResultInformationResult.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ExecuteResponsePuzzleResultInformationResult = + (typeof ExecuteResponsePuzzleResultInformationResult)[keyof typeof ExecuteResponsePuzzleResultInformationResult]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ExecuteResponsePuzzleResultInformationResult = { + SUCCESS: "SUCCESS", + ERROR: "ERROR" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts b/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts new file mode 100644 index 00000000..2659d7a4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/executeResponseRun.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ExecuteResponseRun = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts new file mode 100644 index 00000000..d7932807 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponse.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GameResponseOwner } from "./gameResponseOwner"; +import type { GameResponsePlayersItem } from "./gameResponsePlayersItem"; +import type { GameResponsePuzzle } from "./gameResponsePuzzle"; + +export interface GameResponse { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: GameResponseOwner; + players?: GameResponsePlayersItem[]; + puzzle?: GameResponsePuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts new file mode 100644 index 00000000..54c4ec42 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponseOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponseOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts new file mode 100644 index 00000000..bae94098 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponsePlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponsePlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts new file mode 100644 index 00000000..3eb5131f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameResponsePuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GameResponsePuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts new file mode 100644 index 00000000..432832d6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/gameSubmitCodeRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * Request to link a submission to a game. This is the correct type for game submissions (not to be confused with SubmitCodeRequest for direct code submission) + */ +export interface GameSubmitCodeRequest { + /** The ID of the submission to link to the game */ + submissionId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts new file mode 100644 index 00000000..51e1abfa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponse.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GlobalLeaderboardResponseRankingsItem } from "./globalLeaderboardResponseRankingsItem"; + +export interface GlobalLeaderboardResponse { + /** @nullable */ + cachedAt?: string | null; + gameMode?: string; + limit?: number; + offset?: number; + rankings?: GlobalLeaderboardResponseRankingsItem[]; + totalEntries?: number; + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts new file mode 100644 index 00000000..d9901d80 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItem.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { GlobalLeaderboardResponseRankingsItemGlicko } from "./globalLeaderboardResponseRankingsItemGlicko"; + +export type GlobalLeaderboardResponseRankingsItem = { + averageScore?: number; + bestScore?: number; + gamesPlayed?: number; + gamesWon?: number; + glicko?: GlobalLeaderboardResponseRankingsItemGlicko; + puzzlesSolved?: number; + rank?: number; + rating?: number; + totalSubmissions?: number; + userId?: string; + username?: string; + winRate?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts new file mode 100644 index 00000000..fe3578c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/globalLeaderboardResponseRankingsItemGlicko.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type GlobalLeaderboardResponseRankingsItemGlicko = { + /** Rating deviation */ + rd?: number; + /** Volatility */ + vol?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/index.ts b/libs/frontend/src/lib/api/generated/schemas/index.ts new file mode 100644 index 00000000..961f0222 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/index.ts @@ -0,0 +1,215 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export * from "./accountPreferences"; +export * from "./accountPreferencesEditor"; +export * from "./accountPreferencesTheme"; +export * from "./accountProfileUpdateRequest"; +export * from "./accountProfileUpdateResponse"; +export * from "./accountProfileUpdateResponseProfile"; +export * from "./accountStatusResponse"; +export * from "./activityResponse"; +export * from "./activityResponseActivity"; +export * from "./activityResponseActivityPuzzlesItem"; +export * from "./activityResponseActivityPuzzlesItemAuthor"; +export * from "./activityResponseActivityPuzzlesItemAuthorProfile"; +export * from "./activityResponseActivityPuzzlesItemSolution"; +export * from "./activityResponseActivityPuzzlesItemValidatorsItem"; +export * from "./activityResponseActivitySubmissionsItem"; +export * from "./activityResponseActivitySubmissionsItemProgrammingLanguage"; +export * from "./activityResponseActivitySubmissionsItemPuzzle"; +export * from "./activityResponseActivitySubmissionsItemResult"; +export * from "./activityResponseActivitySubmissionsItemUser"; +export * from "./activityResponseActivitySubmissionsItemUserProfile"; +export * from "./activityResponseUser"; +export * from "./activityResponseUserProfile"; +export * from "./authMessageResponse"; +export * from "./author"; +export * from "./authorProfile"; +export * from "./availabilityResponse"; +export * from "./banResponse"; +export * from "./banUserRequest"; +export * from "./codincodApiWebHealthControllerShow200"; +export * from "./codincodApiWebHealthControllerShow2200"; +export * from "./codincodApiWebLeaderboardControllerGlobal2GameMode"; +export * from "./codincodApiWebLeaderboardControllerGlobal2Params"; +export * from "./codincodApiWebLeaderboardControllerGlobalGameMode"; +export * from "./codincodApiWebLeaderboardControllerGlobalParams"; +export * from "./codincodApiWebLeaderboardControllerPuzzle2Params"; +export * from "./codincodApiWebLeaderboardControllerPuzzleParams"; +export * from "./codincodApiWebModerationControllerListReports2Params"; +export * from "./codincodApiWebModerationControllerListReports2ProblemType"; +export * from "./codincodApiWebModerationControllerListReports2Status"; +export * from "./codincodApiWebModerationControllerListReportsParams"; +export * from "./codincodApiWebModerationControllerListReportsProblemType"; +export * from "./codincodApiWebModerationControllerListReportsStatus"; +export * from "./codincodApiWebModerationControllerListReviews2Params"; +export * from "./codincodApiWebModerationControllerListReviews2Status"; +export * from "./codincodApiWebModerationControllerListReviewsParams"; +export * from "./codincodApiWebModerationControllerListReviewsStatus"; +export * from "./codincodApiWebProgrammingLanguageControllerIndex200Item"; +export * from "./codincodApiWebProgrammingLanguageControllerIndex2200Item"; +export * from "./codincodApiWebPuzzleControllerIndex2Params"; +export * from "./codincodApiWebPuzzleControllerIndexParams"; +export * from "./codincodApiWebUserControllerPuzzles2Params"; +export * from "./codincodApiWebUserControllerPuzzlesParams"; +export * from "./commentCreateRequest"; +export * from "./commentResponse"; +export * from "./commentResponseAuthor"; +export * from "./commentResponseCommentType"; +export * from "./commentVoteRequest"; +export * from "./commentVoteRequestType"; +export * from "./createGameRequest"; +export * from "./createGameRequestGameMode"; +export * from "./createReportRequest"; +export * from "./createReportRequestContentType"; +export * from "./createReportRequestProblemType"; +export * from "./createRequest"; +export * from "./createRequestDifficulty"; +export * from "./createRequestValidatorsItem"; +export * from "./errorResponse"; +export * from "./errorResponseErrors"; +export * from "./executeRequest"; +export * from "./executeResponse"; +export * from "./executeResponseCompile"; +export * from "./executeResponsePuzzleResultInformation"; +export * from "./executeResponsePuzzleResultInformationResult"; +export * from "./executeResponseRun"; +export * from "./gameResponse"; +export * from "./gameResponseOwner"; +export * from "./gameResponsePlayersItem"; +export * from "./gameResponsePuzzle"; +export * from "./gameSubmitCodeRequest"; +export * from "./globalLeaderboardResponse"; +export * from "./globalLeaderboardResponseRankingsItem"; +export * from "./globalLeaderboardResponseRankingsItemGlicko"; +export * from "./leaveGameResponse"; +export * from "./loginRequest"; +export * from "./messageResponse"; +export * from "./paginatedListResponse"; +export * from "./paginatedListResponseItemsItem"; +export * from "./paginatedListResponseItemsItemAuthor"; +export * from "./paginatedListResponseItemsItemAuthorProfile"; +export * from "./paginatedListResponseItemsItemSolution"; +export * from "./paginatedListResponseItemsItemValidatorsItem"; +export * from "./passwordResetCompleteResponse"; +export * from "./passwordResetPayload"; +export * from "./passwordResetRequest"; +export * from "./passwordResetResponse"; +export * from "./platformMetricsResponse"; +export * from "./platformMetricsResponsePopularPuzzlesItem"; +export * from "./preferencesPayload"; +export * from "./preferencesPayloadEditor"; +export * from "./preferencesPayloadTheme"; +export * from "./profile"; +export * from "./profileUpdateRequest"; +export * from "./profileUpdateResponse"; +export * from "./profileUpdateResponseProfile"; +export * from "./programmingLanguageSummary"; +export * from "./puzzleCreateRequest"; +export * from "./puzzleCreateRequestDifficulty"; +export * from "./puzzleCreateRequestValidatorsItem"; +export * from "./puzzleLeaderboardResponse"; +export * from "./puzzleLeaderboardResponseRankingsItem"; +export * from "./puzzlePaginatedListResponse"; +export * from "./puzzlePaginatedListResponseItemsItem"; +export * from "./puzzlePaginatedListResponseItemsItemAuthor"; +export * from "./puzzlePaginatedListResponseItemsItemAuthorProfile"; +export * from "./puzzlePaginatedListResponseItemsItemSolution"; +export * from "./puzzlePaginatedListResponseItemsItemValidatorsItem"; +export * from "./puzzleResponse"; +export * from "./puzzleResponseAuthor"; +export * from "./puzzleResponseAuthorProfile"; +export * from "./puzzleResponseSolution"; +export * from "./puzzleResponseValidatorsItem"; +export * from "./puzzleResultInformation"; +export * from "./puzzleResultInformationResult"; +export * from "./puzzleStatsResponse"; +export * from "./puzzleStatsResponseLanguageDistributionItem"; +export * from "./puzzleStatsResponseStatusBreakdown"; +export * from "./puzzleSummary"; +export * from "./registerRequest"; +export * from "./reportResponse"; +export * from "./reportResponseReportedBy"; +export * from "./reportResponseResolvedBy"; +export * from "./reportsListResponse"; +export * from "./requestPayload"; +export * from "./requestResponse"; +export * from "./resetPayload"; +export * from "./resetResponse"; +export * from "./resolveReportRequest"; +export * from "./resolveReportRequestStatus"; +export * from "./reviewDecisionRequest"; +export * from "./reviewDecisionRequestStatus"; +export * from "./reviewResponse"; +export * from "./reviewResponseContextMessagesItem"; +export * from "./reviewResponseReviewer"; +export * from "./reviewsListResponse"; +export * from "./showResponse"; +export * from "./showResponseUser"; +export * from "./showResponseUserProfile"; +export * from "./solution"; +export * from "./submissionListResponse"; +export * from "./submissionListResponseItem"; +export * from "./submissionListResponseItemProgrammingLanguage"; +export * from "./submissionListResponseItemPuzzle"; +export * from "./submissionListResponseItemResult"; +export * from "./submissionListResponseItemUser"; +export * from "./submissionListResponseItemUserProfile"; +export * from "./submissionResponse"; +export * from "./submissionResponseProgrammingLanguage"; +export * from "./submissionResponsePuzzle"; +export * from "./submissionResponseResult"; +export * from "./submissionResponseUser"; +export * from "./submissionResponseUserProfile"; +export * from "./submissionSubmitRequest"; +export * from "./submissionSubmitResponse"; +export * from "./submissionSubmitResponseResult"; +export * from "./submitCodeRequest"; +export * from "./submitCodeResponse"; +export * from "./submitCodeResponseResult"; +export * from "./summary"; +export * from "./summaryProfile"; +export * from "./userActivityResponse"; +export * from "./userActivityResponseActivity"; +export * from "./userActivityResponseActivityPuzzlesItem"; +export * from "./userActivityResponseActivityPuzzlesItemAuthor"; +export * from "./userActivityResponseActivityPuzzlesItemAuthorProfile"; +export * from "./userActivityResponseActivityPuzzlesItemSolution"; +export * from "./userActivityResponseActivityPuzzlesItemValidatorsItem"; +export * from "./userActivityResponseActivitySubmissionsItem"; +export * from "./userActivityResponseActivitySubmissionsItemProgrammingLanguage"; +export * from "./userActivityResponseActivitySubmissionsItemPuzzle"; +export * from "./userActivityResponseActivitySubmissionsItemResult"; +export * from "./userActivityResponseActivitySubmissionsItemUser"; +export * from "./userActivityResponseActivitySubmissionsItemUserProfile"; +export * from "./userActivityResponseUser"; +export * from "./userActivityResponseUserProfile"; +export * from "./userAvailabilityResponse"; +export * from "./userGamesResponse"; +export * from "./userGamesResponseGamesItem"; +export * from "./userGamesResponseGamesItemOwner"; +export * from "./userGamesResponseGamesItemPlayersItem"; +export * from "./userGamesResponseGamesItemPuzzle"; +export * from "./userRankResponse"; +export * from "./userShowResponse"; +export * from "./userShowResponseUser"; +export * from "./userShowResponseUserProfile"; +export * from "./userStatsResponse"; +export * from "./userStatsResponseDifficultyBreakdown"; +export * from "./userStatsResponseLanguageUsageItem"; +export * from "./userSummary"; +export * from "./userSummaryProfile"; +export * from "./validator"; +export * from "./voteRequest"; +export * from "./voteRequestType"; +export * from "./waitingRoomsResponse"; +export * from "./waitingRoomsResponseRoomsItem"; +export * from "./waitingRoomsResponseRoomsItemOwner"; +export * from "./waitingRoomsResponseRoomsItemPlayersItem"; +export * from "./waitingRoomsResponseRoomsItemPuzzle"; diff --git a/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts b/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts new file mode 100644 index 00000000..c6ef785e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/leaveGameResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface LeaveGameResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts b/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts new file mode 100644 index 00000000..afe2f06c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/loginRequest.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface LoginRequest { + /** Username or email */ + identifier: string; + password: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts b/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts new file mode 100644 index 00000000..87b015b5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/messageResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface MessageResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts new file mode 100644 index 00000000..7b8bb9d4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponse.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItem } from "./paginatedListResponseItemsItem"; + +export interface PaginatedListResponse { + items?: PaginatedListResponseItemsItem[]; + /** @minimum 1 */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; + /** @minimum 0 */ + totalItems?: number; + /** @minimum 0 */ + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts new file mode 100644 index 00000000..92560abe --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItemAuthor } from "./paginatedListResponseItemsItemAuthor"; +import type { PaginatedListResponseItemsItemSolution } from "./paginatedListResponseItemsItemSolution"; +import type { PaginatedListResponseItemsItemValidatorsItem } from "./paginatedListResponseItemsItemValidatorsItem"; + +export type PaginatedListResponseItemsItem = { + _id?: string; + author?: PaginatedListResponseItemsItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PaginatedListResponseItemsItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PaginatedListResponseItemsItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts new file mode 100644 index 00000000..40712f96 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PaginatedListResponseItemsItemAuthorProfile } from "./paginatedListResponseItemsItemAuthorProfile"; + +export type PaginatedListResponseItemsItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PaginatedListResponseItemsItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts new file mode 100644 index 00000000..f355adfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts new file mode 100644 index 00000000..379cb653 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts new file mode 100644 index 00000000..4ee0f8c7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/paginatedListResponseItemsItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PaginatedListResponseItemsItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts new file mode 100644 index 00000000..73fbf7ea --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetCompleteResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetCompleteResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts new file mode 100644 index 00000000..3e1963e5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetPayload.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetPayload { + /** @minLength 8 */ + password: string; + token: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts new file mode 100644 index 00000000..2ef76104 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetRequest.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetRequest { + email: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts b/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts new file mode 100644 index 00000000..7e82d8ff --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/passwordResetResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PasswordResetResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts new file mode 100644 index 00000000..4c514484 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponse.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PlatformMetricsResponsePopularPuzzlesItem } from "./platformMetricsResponsePopularPuzzlesItem"; + +export interface PlatformMetricsResponse { + acceptedSubmissions?: number; + activeUsers?: number; + popularPuzzles?: PlatformMetricsResponsePopularPuzzlesItem[]; + totalPuzzles?: number; + totalSubmissions?: number; + totalUsers?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts new file mode 100644 index 00000000..2b907ff6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/platformMetricsResponsePopularPuzzlesItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PlatformMetricsResponsePopularPuzzlesItem = { + difficulty?: string; + puzzleId?: string; + submissionCount?: number; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts new file mode 100644 index 00000000..16e0cee2 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayload.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PreferencesPayloadEditor } from "./preferencesPayloadEditor"; +import type { PreferencesPayloadTheme } from "./preferencesPayloadTheme"; + +export interface PreferencesPayload { + blockedUsers?: string[]; + editor?: PreferencesPayloadEditor; + /** @nullable */ + preferredLanguage?: string | null; + /** @nullable */ + theme?: PreferencesPayloadTheme; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts new file mode 100644 index 00000000..ddb82c14 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadEditor.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PreferencesPayloadEditor = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts new file mode 100644 index 00000000..3be87e99 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/preferencesPayloadTheme.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type PreferencesPayloadTheme = + | (typeof PreferencesPayloadTheme)[keyof typeof PreferencesPayloadTheme] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PreferencesPayloadTheme = { + dark: "dark", + light: "light" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/profile.ts b/libs/frontend/src/lib/api/generated/schemas/profile.ts new file mode 100644 index 00000000..729d5e90 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Profile { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts new file mode 100644 index 00000000..c57f41c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateRequest.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ProfileUpdateRequest { + /** @maxLength 500 */ + bio?: string; + /** @maxLength 100 */ + location?: string; + picture?: string; + /** @maxItems 5 */ + socials?: string[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts new file mode 100644 index 00000000..718cf80d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ProfileUpdateResponseProfile } from "./profileUpdateResponseProfile"; + +export interface ProfileUpdateResponse { + message?: string; + profile?: ProfileUpdateResponseProfile; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts new file mode 100644 index 00000000..64011600 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/profileUpdateResponseProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ProfileUpdateResponseProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts b/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts new file mode 100644 index 00000000..89b6f86e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/programmingLanguageSummary.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ProgrammingLanguageSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts new file mode 100644 index 00000000..e0745380 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequest.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleCreateRequestDifficulty } from "./puzzleCreateRequestDifficulty"; +import type { PuzzleCreateRequestValidatorsItem } from "./puzzleCreateRequestValidatorsItem"; + +export interface PuzzleCreateRequest { + /** @nullable */ + constraints?: string | null; + /** + * @minLength 1 + * @nullable + */ + description?: string | null; + /** @nullable */ + difficulty?: PuzzleCreateRequestDifficulty; + /** @nullable */ + tags?: string[] | null; + /** + * @minLength 4 + * @maxLength 128 + */ + title: string; + /** @nullable */ + validators?: PuzzleCreateRequestValidatorsItem[] | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts new file mode 100644 index 00000000..1a4fb40c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestDifficulty.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type PuzzleCreateRequestDifficulty = + | (typeof PuzzleCreateRequestDifficulty)[keyof typeof PuzzleCreateRequestDifficulty] + | null; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PuzzleCreateRequestDifficulty = { + easy: "easy", + medium: "medium", + hard: "hard", + beginner: "beginner", + intermediate: "intermediate", + advanced: "advanced", + expert: "expert" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts new file mode 100644 index 00000000..672edd3d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleCreateRequestValidatorsItem.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleCreateRequestValidatorsItem = { + input: string; + isPublic?: boolean; + output: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts new file mode 100644 index 00000000..e677f9b0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleLeaderboardResponseRankingsItem } from "./puzzleLeaderboardResponseRankingsItem"; + +export interface PuzzleLeaderboardResponse { + limit?: number; + puzzleId?: string; + rankings?: PuzzleLeaderboardResponseRankingsItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts new file mode 100644 index 00000000..e5fb9098 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleLeaderboardResponseRankingsItem.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleLeaderboardResponseRankingsItem = { + executionTime?: number; + memoryUsed?: number; + rank?: number; + submittedAt?: string; + userId?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts new file mode 100644 index 00000000..54285ae1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponse.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItem } from "./puzzlePaginatedListResponseItemsItem"; + +export interface PuzzlePaginatedListResponse { + items?: PuzzlePaginatedListResponseItemsItem[]; + /** @minimum 1 */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + pageSize?: number; + /** @minimum 0 */ + totalItems?: number; + /** @minimum 0 */ + totalPages?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts new file mode 100644 index 00000000..dacc51ca --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItemAuthor } from "./puzzlePaginatedListResponseItemsItemAuthor"; +import type { PuzzlePaginatedListResponseItemsItemSolution } from "./puzzlePaginatedListResponseItemsItemSolution"; +import type { PuzzlePaginatedListResponseItemsItemValidatorsItem } from "./puzzlePaginatedListResponseItemsItemValidatorsItem"; + +export type PuzzlePaginatedListResponseItemsItem = { + _id?: string; + author?: PuzzlePaginatedListResponseItemsItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PuzzlePaginatedListResponseItemsItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PuzzlePaginatedListResponseItemsItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts new file mode 100644 index 00000000..bcae81bb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzlePaginatedListResponseItemsItemAuthorProfile } from "./puzzlePaginatedListResponseItemsItemAuthorProfile"; + +export type PuzzlePaginatedListResponseItemsItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PuzzlePaginatedListResponseItemsItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts new file mode 100644 index 00000000..86af91a8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts new file mode 100644 index 00000000..d8e18a6d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts new file mode 100644 index 00000000..fcff9d57 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzlePaginatedListResponseItemsItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzlePaginatedListResponseItemsItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts new file mode 100644 index 00000000..2642940a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponse.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResponseAuthor } from "./puzzleResponseAuthor"; +import type { PuzzleResponseSolution } from "./puzzleResponseSolution"; +import type { PuzzleResponseValidatorsItem } from "./puzzleResponseValidatorsItem"; + +export interface PuzzleResponse { + _id?: string; + author?: PuzzleResponseAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: PuzzleResponseSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: PuzzleResponseValidatorsItem[]; + visibility?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts new file mode 100644 index 00000000..8c6f1b70 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResponseAuthorProfile } from "./puzzleResponseAuthorProfile"; + +export type PuzzleResponseAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: PuzzleResponseAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts new file mode 100644 index 00000000..dc58bcd4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts new file mode 100644 index 00000000..4f4c2a8c --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts new file mode 100644 index 00000000..f2f234b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResponseValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResponseValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts new file mode 100644 index 00000000..b2e7b351 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformation.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleResultInformationResult } from "./puzzleResultInformationResult"; + +export interface PuzzleResultInformation { + /** @minimum 0 */ + failed?: number; + /** @minimum 0 */ + passed?: number; + result?: PuzzleResultInformationResult; + /** + * @minimum 0 + * @maximum 1 + */ + successRate?: number; + /** @minimum 1 */ + total?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts new file mode 100644 index 00000000..3f566315 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleResultInformationResult.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleResultInformationResult = + (typeof PuzzleResultInformationResult)[keyof typeof PuzzleResultInformationResult]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const PuzzleResultInformationResult = { + SUCCESS: "SUCCESS", + ERROR: "ERROR" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts new file mode 100644 index 00000000..ba2983b3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponse.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { PuzzleStatsResponseLanguageDistributionItem } from "./puzzleStatsResponseLanguageDistributionItem"; +import type { PuzzleStatsResponseStatusBreakdown } from "./puzzleStatsResponseStatusBreakdown"; + +export interface PuzzleStatsResponse { + acceptanceRate?: number; + acceptedSubmissions?: number; + /** @nullable */ + averageExecutionTime?: number | null; + languageDistribution?: PuzzleStatsResponseLanguageDistributionItem[]; + puzzleId?: string; + statusBreakdown?: PuzzleStatsResponseStatusBreakdown; + title?: string; + totalSubmissions?: number; + uniqueSolvers?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts new file mode 100644 index 00000000..4205c854 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseLanguageDistributionItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleStatsResponseLanguageDistributionItem = { + count?: number; + language?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts new file mode 100644 index 00000000..e4168762 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleStatsResponseStatusBreakdown.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type PuzzleStatsResponseStatusBreakdown = { + accepted?: number; + runtimeError?: number; + timeLimitExceeded?: number; + wrongAnswer?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts b/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts new file mode 100644 index 00000000..8d1333c5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/puzzleSummary.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface PuzzleSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts b/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts new file mode 100644 index 00000000..883f67b4 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/registerRequest.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RegisterRequest { + email: string; + /** @minLength 14 */ + password: string; + passwordConfirmation?: string; + /** + * @minLength 3 + * @maxLength 20 + */ + username: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts new file mode 100644 index 00000000..f73a1137 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponse.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReportResponseReportedBy } from "./reportResponseReportedBy"; +import type { ReportResponseResolvedBy } from "./reportResponseResolvedBy"; + +export interface ReportResponse { + contentId?: string; + contentType?: string; + createdAt?: string; + /** @nullable */ + description?: string | null; + id?: string; + problemType?: string; + /** @nullable */ + reportedBy?: ReportResponseReportedBy; + /** @nullable */ + resolutionNotes?: string | null; + /** @nullable */ + resolvedAt?: string | null; + /** @nullable */ + resolvedBy?: ReportResponseResolvedBy; + status?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts new file mode 100644 index 00000000..3411fbb7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponseReportedBy.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReportResponseReportedBy = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts b/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts new file mode 100644 index 00000000..408f372b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportResponseResolvedBy.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReportResponseResolvedBy = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts new file mode 100644 index 00000000..59379b02 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reportsListResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReportResponse } from "./reportResponse"; + +export interface ReportsListResponse { + count?: number; + reports?: ReportResponse[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts b/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts new file mode 100644 index 00000000..86f09814 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/requestPayload.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RequestPayload { + email: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts b/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts new file mode 100644 index 00000000..32684b35 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/requestResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface RequestResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts b/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts new file mode 100644 index 00000000..18eb3739 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resetPayload.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ResetPayload { + /** @minLength 8 */ + password: string; + token: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts b/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts new file mode 100644 index 00000000..8b05d064 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resetResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface ResetResponse { + message?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts new file mode 100644 index 00000000..f2f37ce1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequest.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ResolveReportRequestStatus } from "./resolveReportRequestStatus"; + +export interface ResolveReportRequest { + /** @nullable */ + resolutionNotes?: string | null; + status: ResolveReportRequestStatus; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts new file mode 100644 index 00000000..91e95d91 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/resolveReportRequestStatus.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ResolveReportRequestStatus = + (typeof ResolveReportRequestStatus)[keyof typeof ResolveReportRequestStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ResolveReportRequestStatus = { + resolved: "resolved", + dismissed: "dismissed" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts new file mode 100644 index 00000000..1da045e3 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequest.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewDecisionRequestStatus } from "./reviewDecisionRequestStatus"; + +export interface ReviewDecisionRequest { + /** @nullable */ + reviewerNotes?: string | null; + status: ReviewDecisionRequestStatus; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts new file mode 100644 index 00000000..3e712b1e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewDecisionRequestStatus.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ReviewDecisionRequestStatus = + (typeof ReviewDecisionRequestStatus)[keyof typeof ReviewDecisionRequestStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ReviewDecisionRequestStatus = { + approved: "approved", + rejected: "rejected" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts new file mode 100644 index 00000000..49352050 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponse.ts @@ -0,0 +1,43 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewResponseContextMessagesItem } from "./reviewResponseContextMessagesItem"; +import type { ReviewResponseReviewer } from "./reviewResponseReviewer"; + +export interface ReviewResponse { + /** @nullable */ + authorName?: string | null; + /** @nullable */ + contextMessages?: ReviewResponseContextMessagesItem[] | null; + createdAt?: string; + /** @nullable */ + description?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + puzzleId?: string | null; + /** @nullable */ + reportExplanation?: string | null; + /** @nullable */ + reportedBy?: string | null; + /** @nullable */ + reportedMessageId?: string | null; + /** @nullable */ + reportedUserId?: string | null; + /** @nullable */ + reportedUserName?: string | null; + /** @nullable */ + reviewedAt?: string | null; + /** @nullable */ + reviewer?: ReviewResponseReviewer; + /** @nullable */ + reviewerNotes?: string | null; + status?: string; + /** @nullable */ + title?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts new file mode 100644 index 00000000..d8674ec1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponseContextMessagesItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ReviewResponseContextMessagesItem = { + _id?: string; + message?: string; + timestamp?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts b/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts new file mode 100644 index 00000000..c514fe83 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewResponseReviewer.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +/** + * @nullable + */ +export type ReviewResponseReviewer = { + id?: string; + username?: string; +} | null; diff --git a/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts new file mode 100644 index 00000000..b086abff --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/reviewsListResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ReviewResponse } from "./reviewResponse"; + +export interface ReviewsListResponse { + count?: number; + reviews?: ReviewResponse[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponse.ts b/libs/frontend/src/lib/api/generated/schemas/showResponse.ts new file mode 100644 index 00000000..495e7b1e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ShowResponseUser } from "./showResponseUser"; + +export interface ShowResponse { + message?: string; + user?: ShowResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts new file mode 100644 index 00000000..ab2eea01 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { ShowResponseUserProfile } from "./showResponseUserProfile"; + +export type ShowResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: ShowResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts new file mode 100644 index 00000000..13ece811 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/showResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type ShowResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/solution.ts b/libs/frontend/src/lib/api/generated/schemas/solution.ts new file mode 100644 index 00000000..d2b80463 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/solution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Solution { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts new file mode 100644 index 00000000..70d8ebb8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponse.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItem } from "./submissionListResponseItem"; + +export type SubmissionListResponse = SubmissionListResponseItem[]; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts new file mode 100644 index 00000000..bfa710cf --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItemProgrammingLanguage } from "./submissionListResponseItemProgrammingLanguage"; +import type { SubmissionListResponseItemPuzzle } from "./submissionListResponseItemPuzzle"; +import type { SubmissionListResponseItemResult } from "./submissionListResponseItemResult"; +import type { SubmissionListResponseItemUser } from "./submissionListResponseItemUser"; + +export type SubmissionListResponseItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: SubmissionListResponseItemProgrammingLanguage; + puzzle?: SubmissionListResponseItemPuzzle; + result?: SubmissionListResponseItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: SubmissionListResponseItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts new file mode 100644 index 00000000..df243ebd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts new file mode 100644 index 00000000..ed27bda6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts new file mode 100644 index 00000000..95a1005d --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemResult.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemResult = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts new file mode 100644 index 00000000..0f69aa1f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionListResponseItemUserProfile } from "./submissionListResponseItemUserProfile"; + +export type SubmissionListResponseItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SubmissionListResponseItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts new file mode 100644 index 00000000..8a2a1e3a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionListResponseItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionListResponseItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts new file mode 100644 index 00000000..1e765921 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponse.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionResponseProgrammingLanguage } from "./submissionResponseProgrammingLanguage"; +import type { SubmissionResponsePuzzle } from "./submissionResponsePuzzle"; +import type { SubmissionResponseResult } from "./submissionResponseResult"; +import type { SubmissionResponseUser } from "./submissionResponseUser"; + +export interface SubmissionResponse { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: SubmissionResponseProgrammingLanguage; + puzzle?: SubmissionResponsePuzzle; + result?: SubmissionResponseResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: SubmissionResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts new file mode 100644 index 00000000..d6e2ecbe --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts new file mode 100644 index 00000000..b443cd9b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponsePuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponsePuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts new file mode 100644 index 00000000..20ab41ed --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseResult.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseResult = { [key: string]: unknown }; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts new file mode 100644 index 00000000..1d9b132b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionResponseUserProfile } from "./submissionResponseUserProfile"; + +export type SubmissionResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SubmissionResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts new file mode 100644 index 00000000..d67ad89b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts new file mode 100644 index 00000000..cff2d6d0 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface SubmissionSubmitRequest { + /** @minLength 1 */ + code: string; + programmingLanguageId: string; + puzzleId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts new file mode 100644 index 00000000..cf2fec2a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponse.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmissionSubmitResponseResult } from "./submissionSubmitResponseResult"; + +export interface SubmissionSubmitResponse { + code: string; + /** @minimum 0 */ + codeLength: number; + createdAt: string; + programmingLanguageId: string; + puzzleId: string; + result: SubmissionSubmitResponseResult; + submissionId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts new file mode 100644 index 00000000..428d26b1 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submissionSubmitResponseResult.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmissionSubmitResponseResult = { + /** @minimum 0 */ + failed: number; + /** @minimum 0 */ + passed: number; + /** + * @minimum 0 + * @maximum 1 + */ + successRate: number; + /** @minimum 1 */ + total: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts new file mode 100644 index 00000000..ecb30338 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeRequest.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface SubmitCodeRequest { + /** @minLength 1 */ + code: string; + programmingLanguageId: string; + puzzleId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts new file mode 100644 index 00000000..6e724baa --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponse.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SubmitCodeResponseResult } from "./submitCodeResponseResult"; + +export interface SubmitCodeResponse { + code: string; + /** @minimum 0 */ + codeLength: number; + createdAt: string; + programmingLanguageId: string; + puzzleId: string; + result: SubmitCodeResponseResult; + submissionId: string; + userId: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts new file mode 100644 index 00000000..6a57ffd7 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/submitCodeResponseResult.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SubmitCodeResponseResult = { + /** @minimum 0 */ + failed: number; + /** @minimum 0 */ + passed: number; + /** + * @minimum 0 + * @maximum 1 + */ + successRate: number; + /** @minimum 1 */ + total: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/summary.ts b/libs/frontend/src/lib/api/generated/schemas/summary.ts new file mode 100644 index 00000000..51d0143e --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/summary.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { SummaryProfile } from "./summaryProfile"; + +export interface Summary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: SummaryProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts b/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts new file mode 100644 index 00000000..8cbe6fab --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/summaryProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type SummaryProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts new file mode 100644 index 00000000..bfb6aa4b --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivity } from "./userActivityResponseActivity"; +import type { UserActivityResponseUser } from "./userActivityResponseUser"; + +export interface UserActivityResponse { + activity?: UserActivityResponseActivity; + message?: string; + user?: UserActivityResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts new file mode 100644 index 00000000..13bacc75 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivity.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItem } from "./userActivityResponseActivityPuzzlesItem"; +import type { UserActivityResponseActivitySubmissionsItem } from "./userActivityResponseActivitySubmissionsItem"; + +export type UserActivityResponseActivity = { + puzzles?: UserActivityResponseActivityPuzzlesItem[]; + submissions?: UserActivityResponseActivitySubmissionsItem[]; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts new file mode 100644 index 00000000..fbac0093 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItem.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItemAuthor } from "./userActivityResponseActivityPuzzlesItemAuthor"; +import type { UserActivityResponseActivityPuzzlesItemSolution } from "./userActivityResponseActivityPuzzlesItemSolution"; +import type { UserActivityResponseActivityPuzzlesItemValidatorsItem } from "./userActivityResponseActivityPuzzlesItemValidatorsItem"; + +export type UserActivityResponseActivityPuzzlesItem = { + _id?: string; + author?: UserActivityResponseActivityPuzzlesItemAuthor; + comments?: string[]; + /** @nullable */ + constraints?: string | null; + /** @nullable */ + createdAt?: string | null; + difficulty?: string; + id?: string; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyMetricsId?: string | null; + /** @nullable */ + moderationFeedback?: string | null; + /** @nullable */ + puzzleMetrics?: string | null; + solution?: UserActivityResponseActivityPuzzlesItemSolution; + /** @nullable */ + statement?: string | null; + tags?: string[]; + title?: string; + /** @nullable */ + updatedAt?: string | null; + validators?: UserActivityResponseActivityPuzzlesItemValidatorsItem[]; + visibility?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts new file mode 100644 index 00000000..6466d803 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthor.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivityPuzzlesItemAuthorProfile } from "./userActivityResponseActivityPuzzlesItemAuthorProfile"; + +export type UserActivityResponseActivityPuzzlesItemAuthor = { + _id?: string; + createdAt?: string; + id?: string; + profile?: UserActivityResponseActivityPuzzlesItemAuthorProfile; + role?: string; + updatedAt?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts new file mode 100644 index 00000000..0b143f25 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemAuthorProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemAuthorProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts new file mode 100644 index 00000000..417c8fc5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemSolution.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemSolution = { + code?: string; + /** @nullable */ + programmingLanguage?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts new file mode 100644 index 00000000..359dca86 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivityPuzzlesItemValidatorsItem.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivityPuzzlesItemValidatorsItem = { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts new file mode 100644 index 00000000..04665e70 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItem.ts @@ -0,0 +1,34 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivitySubmissionsItemProgrammingLanguage } from "./userActivityResponseActivitySubmissionsItemProgrammingLanguage"; +import type { UserActivityResponseActivitySubmissionsItemPuzzle } from "./userActivityResponseActivitySubmissionsItemPuzzle"; +import type { UserActivityResponseActivitySubmissionsItemResult } from "./userActivityResponseActivitySubmissionsItemResult"; +import type { UserActivityResponseActivitySubmissionsItemUser } from "./userActivityResponseActivitySubmissionsItemUser"; + +export type UserActivityResponseActivitySubmissionsItem = { + _id?: string; + /** @nullable */ + code?: string | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + gameId?: string | null; + id?: string; + /** @nullable */ + legacyGameSubmissionId?: string | null; + /** @nullable */ + legacyId?: string | null; + programmingLanguage?: UserActivityResponseActivitySubmissionsItemProgrammingLanguage; + puzzle?: UserActivityResponseActivitySubmissionsItemPuzzle; + result?: UserActivityResponseActivitySubmissionsItemResult; + /** @nullable */ + score?: number | null; + /** @nullable */ + updatedAt?: string | null; + user?: UserActivityResponseActivitySubmissionsItemUser; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts new file mode 100644 index 00000000..7b5a6183 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemProgrammingLanguage.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemProgrammingLanguage = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + language?: string | null; + /** @nullable */ + runtime?: string | null; + /** @nullable */ + version?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts new file mode 100644 index 00000000..96e3ad7f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemPuzzle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemPuzzle = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + title?: string | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts new file mode 100644 index 00000000..c53e6dfd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemResult.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemResult = { + [key: string]: unknown; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts new file mode 100644 index 00000000..08652d43 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseActivitySubmissionsItemUserProfile } from "./userActivityResponseActivitySubmissionsItemUserProfile"; + +export type UserActivityResponseActivitySubmissionsItemUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserActivityResponseActivitySubmissionsItemUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts new file mode 100644 index 00000000..25f49f55 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseActivitySubmissionsItemUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseActivitySubmissionsItemUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts new file mode 100644 index 00000000..1d6ccff5 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserActivityResponseUserProfile } from "./userActivityResponseUserProfile"; + +export type UserActivityResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserActivityResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts new file mode 100644 index 00000000..e7191959 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userActivityResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserActivityResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts new file mode 100644 index 00000000..97161712 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userAvailabilityResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface UserAvailabilityResponse { + available?: boolean; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts new file mode 100644 index 00000000..95e6b374 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserGamesResponseGamesItem } from "./userGamesResponseGamesItem"; + +export interface UserGamesResponse { + count?: number; + games?: UserGamesResponseGamesItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts new file mode 100644 index 00000000..fc3b33cb --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItem.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserGamesResponseGamesItemOwner } from "./userGamesResponseGamesItemOwner"; +import type { UserGamesResponseGamesItemPlayersItem } from "./userGamesResponseGamesItemPlayersItem"; +import type { UserGamesResponseGamesItemPuzzle } from "./userGamesResponseGamesItemPuzzle"; + +export type UserGamesResponseGamesItem = { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: UserGamesResponseGamesItemOwner; + players?: UserGamesResponseGamesItemPlayersItem[]; + puzzle?: UserGamesResponseGamesItemPuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts new file mode 100644 index 00000000..3ca8d1cd --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts new file mode 100644 index 00000000..44aa1f52 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemPlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts new file mode 100644 index 00000000..eb7124c8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userGamesResponseGamesItemPuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserGamesResponseGamesItemPuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts new file mode 100644 index 00000000..ea51fe13 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userRankResponse.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface UserRankResponse { + /** @nullable */ + puzzlesSolved?: number | null; + /** @nullable */ + rank?: number | null; + /** @nullable */ + rating?: number | null; + /** @nullable */ + totalSubmissions?: number | null; + userId?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts new file mode 100644 index 00000000..dbba5e33 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserShowResponseUser } from "./userShowResponseUser"; + +export interface UserShowResponse { + message?: string; + user?: UserShowResponseUser; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts new file mode 100644 index 00000000..061c3121 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUser.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserShowResponseUserProfile } from "./userShowResponseUserProfile"; + +export type UserShowResponseUser = { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserShowResponseUserProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts new file mode 100644 index 00000000..dad95fd6 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userShowResponseUserProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserShowResponseUserProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts new file mode 100644 index 00000000..7acd65cc --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponse.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserStatsResponseDifficultyBreakdown } from "./userStatsResponseDifficultyBreakdown"; +import type { UserStatsResponseLanguageUsageItem } from "./userStatsResponseLanguageUsageItem"; + +export interface UserStatsResponse { + acceptanceRate?: number; + acceptedSubmissions?: number; + difficultyBreakdown?: UserStatsResponseDifficultyBreakdown; + languageUsage?: UserStatsResponseLanguageUsageItem[]; + puzzlesSolved?: number; + recentActivity?: number; + runtimeErrors?: number; + timeLimitExceeded?: number; + totalSubmissions?: number; + userId?: string; + username?: string; + wrongAnswerSubmissions?: number; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts new file mode 100644 index 00000000..5094e47f --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseDifficultyBreakdown.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserStatsResponseDifficultyBreakdown = { + easy?: number; + expert?: number; + hard?: number; + medium?: number; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts new file mode 100644 index 00000000..3f57a862 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userStatsResponseLanguageUsageItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserStatsResponseLanguageUsageItem = { + count?: number; + language?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/userSummary.ts b/libs/frontend/src/lib/api/generated/schemas/userSummary.ts new file mode 100644 index 00000000..972dda81 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userSummary.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { UserSummaryProfile } from "./userSummaryProfile"; + +export interface UserSummary { + /** @nullable */ + _id?: string | null; + /** @nullable */ + banCount?: number | null; + /** @nullable */ + createdAt?: string | null; + /** @nullable */ + currentBan?: string | null; + /** @nullable */ + id?: string | null; + /** @nullable */ + legacyId?: string | null; + /** @nullable */ + legacyUsername?: string | null; + profile?: UserSummaryProfile; + /** @nullable */ + reportCount?: number | null; + /** @nullable */ + role?: string | null; + /** @nullable */ + updatedAt?: string | null; + username?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts b/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts new file mode 100644 index 00000000..b1a34334 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/userSummaryProfile.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type UserSummaryProfile = { + /** @nullable */ + bio?: string | null; + /** @nullable */ + location?: string | null; + /** @nullable */ + picture?: string | null; + /** @nullable */ + socials?: string[] | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/validator.ts b/libs/frontend/src/lib/api/generated/schemas/validator.ts new file mode 100644 index 00000000..546db774 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/validator.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export interface Validator { + createdAt?: string; + /** Validator input payload */ + input?: string; + isPublic?: boolean; + /** Expected validator output */ + output?: string; + updatedAt?: string; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts b/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts new file mode 100644 index 00000000..821600db --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/voteRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { VoteRequestType } from "./voteRequestType"; + +export interface VoteRequest { + type: VoteRequestType; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts b/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts new file mode 100644 index 00000000..f7f2d9f9 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/voteRequestType.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type VoteRequestType = + (typeof VoteRequestType)[keyof typeof VoteRequestType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const VoteRequestType = { + upvote: "upvote", + downvote: "downvote" +} as const; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts new file mode 100644 index 00000000..54278446 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { WaitingRoomsResponseRoomsItem } from "./waitingRoomsResponseRoomsItem"; + +export interface WaitingRoomsResponse { + count?: number; + rooms?: WaitingRoomsResponseRoomsItem[]; +} diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts new file mode 100644 index 00000000..c28d3f1a --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItem.ts @@ -0,0 +1,27 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { WaitingRoomsResponseRoomsItemOwner } from "./waitingRoomsResponseRoomsItemOwner"; +import type { WaitingRoomsResponseRoomsItemPlayersItem } from "./waitingRoomsResponseRoomsItemPlayersItem"; +import type { WaitingRoomsResponseRoomsItemPuzzle } from "./waitingRoomsResponseRoomsItemPuzzle"; + +export type WaitingRoomsResponseRoomsItem = { + createdAt?: string; + /** @nullable */ + finishedAt?: string | null; + gameMode?: string; + id?: string; + maxPlayers?: number; + owner?: WaitingRoomsResponseRoomsItemOwner; + players?: WaitingRoomsResponseRoomsItemPlayersItem[]; + puzzle?: WaitingRoomsResponseRoomsItemPuzzle; + /** @nullable */ + startedAt?: string | null; + status?: string; + /** @nullable */ + timeLimit?: number | null; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts new file mode 100644 index 00000000..9cbbab24 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemOwner.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemOwner = { + id?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts new file mode 100644 index 00000000..5795e1f8 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPlayersItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemPlayersItem = { + id?: string; + joinedAt?: string; + role?: string; + username?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts new file mode 100644 index 00000000..2a7af1f2 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/schemas/waitingRoomsResponseRoomsItemPuzzle.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ + +export type WaitingRoomsResponseRoomsItemPuzzle = { + difficulty?: string; + id?: string; + title?: string; +}; diff --git a/libs/frontend/src/lib/api/generated/submission/submission.ts b/libs/frontend/src/lib/api/generated/submission/submission.ts new file mode 100644 index 00000000..43070c63 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/submission/submission.ts @@ -0,0 +1,56 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + SubmissionResponse, + SubmitCodeRequest, + SubmitCodeResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Fetch submission by id + */ +export const getCodincodApiWebSubmissionControllerShowUrl = (id: string) => { + return `/api/submission/${id}`; +}; + +export const codincodApiWebSubmissionControllerShow = async ( + id: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerShowUrl(id), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Submit code for evaluation + */ +export const getCodincodApiWebSubmissionControllerCreateUrl = () => { + return `/api/submission`; +}; + +export const codincodApiWebSubmissionControllerCreate = async ( + submitCodeRequest?: SubmitCodeRequest, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebSubmissionControllerCreateUrl(), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(submitCodeRequest) + } + ); +}; diff --git a/libs/frontend/src/lib/api/generated/user/user.ts b/libs/frontend/src/lib/api/generated/user/user.ts new file mode 100644 index 00000000..621b64a2 --- /dev/null +++ b/libs/frontend/src/lib/api/generated/user/user.ts @@ -0,0 +1,116 @@ +/** + * Generated by orval v7.16.0 🍺 + * Do not edit manually. + * CodinCod API + * Phoenix implementation of the CodinCod backend + * OpenAPI spec version: 0.1.0 + */ +import type { + ActivityResponse, + AvailabilityResponse, + CodincodApiWebUserControllerPuzzlesParams, + PaginatedListResponse, + ShowResponse +} from ".././schemas"; + +import { customClient } from "../../custom-client"; + +/** + * @summary Get user activity (puzzles and submissions) + */ +export const getCodincodApiWebUserControllerActivityUrl = ( + username: string +) => { + return `/api/user/${username}/activity`; +}; + +export const codincodApiWebUserControllerActivity = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerActivityUrl(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary List puzzles authored by a user + */ +export const getCodincodApiWebUserControllerPuzzlesUrl = ( + username: string, + params?: CodincodApiWebUserControllerPuzzlesParams +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/user/${username}/puzzle?${stringifiedParams}` + : `/api/user/${username}/puzzle`; +}; + +export const codincodApiWebUserControllerPuzzles = async ( + username: string, + params?: CodincodApiWebUserControllerPuzzlesParams, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerPuzzlesUrl(username, params), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Get user by username + */ +export const getCodincodApiWebUserControllerShowUrl = (username: string) => { + return `/api/user/${username}`; +}; + +export const codincodApiWebUserControllerShow = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerShowUrl(username), + { + ...options, + method: "GET" + } + ); +}; + +/** + * @summary Check username availability + */ +export const getCodincodApiWebUserControllerAvailabilityUrl = ( + username: string +) => { + return `/api/user/${username}/isAvailable`; +}; + +export const codincodApiWebUserControllerAvailability = async ( + username: string, + options?: RequestInit +): Promise => { + return customClient( + getCodincodApiWebUserControllerAvailabilityUrl(username), + { + ...options, + method: "GET" + } + ); +}; diff --git a/libs/frontend/src/lib/api/notifications.ts b/libs/frontend/src/lib/api/notifications.ts new file mode 100644 index 00000000..fd6ef198 --- /dev/null +++ b/libs/frontend/src/lib/api/notifications.ts @@ -0,0 +1,282 @@ +/** + * User-friendly error notifications for API errors + * + * Provides toast notifications for critical errors with appropriate messaging + * based on error type (network, auth, validation, server error, etc.) + */ + +import { toast } from "svelte-sonner"; +import { ApiError } from "./errors"; + +export interface NotificationOptions { + /** Show notification for this error? Default: true */ + showNotification?: boolean; + + /** Custom title for the notification */ + title?: string; + + /** Custom message override */ + message?: string; + + /** Duration in milliseconds (default: based on severity) */ + duration?: number; + + /** Action button config */ + action?: { + label: string; + onClick: () => void; + }; +} + +/** + * Show appropriate error notification based on error type + * + * @example + * ```typescript + * try { + * await api.post('/api/submit', data); + * } catch (error) { + * showErrorNotification(error, { + * title: 'Submission Failed', + * action: { + * label: 'Retry', + * onClick: () => submitAgain() + * } + * }); + * throw error; + * } + * ``` + */ +export function showErrorNotification( + error: unknown, + options: NotificationOptions = {} +): void { + const { showNotification = true, title, message, duration, action } = options; + + if (!showNotification) return; + + if (error instanceof ApiError) { + // Network/Connection errors (status 0 or 5xx) + if (error.isNetworkError()) { + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: + message || + error.data.message || + "Unable to reach the server. Please check your internet connection and try again.", + duration: duration || 6000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Connection Error", toastOptions); + return; + } + + // Authentication errors (401) + if (error.isStatus(401)) { + const defaultAction = { + label: "Log In", + onClick: () => (window.location.href = "/login") + }; + + toast.error(title || "Authentication Required", { + description: + message || error.data.message || "Please log in to continue.", + duration: duration || 5000, + action: action || defaultAction + }); + return; + } + + // Authorization errors (403) + if (error.isStatus(403)) { + toast.error(title || "Access Denied", { + description: + message || + error.data.message || + "You do not have permission to perform this action.", + duration: duration || 5000 + }); + return; + } + + // Not found errors (404) + if (error.isStatus(404)) { + toast.error(title || "Not Found", { + description: + message || + error.data.message || + "The requested resource could not be found.", + duration: duration || 4000 + }); + return; + } + + // Rate limiting (429) + if (error.isStatus(429)) { + toast.error(title || "Too Many Requests", { + description: + message || + error.data.message || + "You are making requests too quickly. Please wait a moment and try again.", + duration: duration || 5000 + }); + return; + } + + // Validation errors (400) + if (error.isStatus(400)) { + const fieldErrors = error.getFieldErrors(); + const hasFieldErrors = Object.keys(fieldErrors).length > 0; + + toast.error(title || "Validation Error", { + description: + message || + (hasFieldErrors + ? "Please check the form for errors." + : error.data.message || "The data you provided is invalid."), + duration: duration || 5000 + }); + return; + } + + // Server errors (500+) + if (error.status >= 500) { + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: + message || "An error occurred on the server. Please try again later.", + duration: duration || 6000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Server Error", toastOptions); + return; + } + + // Generic API error + const toastOptions: { + description: string; + duration: number; + action?: { label: string; onClick: () => void }; + } = { + description: message || error.data.message || error.message, + duration: duration || 4000 + }; + + if (action) { + toastOptions.action = { + label: action.label, + onClick: action.onClick + }; + } + + toast.error(title || "Error", toastOptions); + return; + } + + // Non-API errors + console.error("Unexpected error:", error); + toast.error(title || "Unexpected Error", { + description: message || "An unexpected error occurred. Please try again.", + duration: duration || 4000 + }); +} + +/** + * Show success notification + */ +export function showSuccessNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 3000 } = options; + + if (title) { + toast.success(title, { + description: message, + duration + }); + } else { + toast.success(message, { duration }); + } +} + +/** + * Show info notification + */ +export function showInfoNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 3000 } = options; + + if (title) { + toast.info(title, { + description: message, + duration + }); + } else { + toast.info(message, { duration }); + } +} + +/** + * Show warning notification + */ +export function showWarningNotification( + message: string, + options: { title?: string; duration?: number } = {} +): void { + const { title, duration = 4000 } = options; + + if (title) { + toast.warning(title, { + description: message, + duration + }); + } else { + toast.warning(message, { duration }); + } +} + +/** + * Wrapper for critical operations that should always notify on error + * + * @example + * ```typescript + * await withErrorNotification( + * () => api.post('/api/submit', data), + * { title: 'Submission Failed' } + * ); + * ``` + */ +export async function withErrorNotification( + operation: () => Promise, + options: NotificationOptions = {} +): Promise { + try { + return await operation(); + } catch (error) { + showErrorNotification(error, options); + throw error; + } +} diff --git a/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte b/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte index f37dec1a..70837883 100644 --- a/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte +++ b/libs/frontend/src/lib/components/external-wrapper/codemirror-wrapper.svelte @@ -212,7 +212,6 @@ ]); $effect(() => { - //eslint-disable-next-line @typescript-eslint/no-unused-expressions value; if (view) untrack(() => update(value)); }); diff --git a/libs/frontend/src/lib/components/nav/navigation/navigation.svelte b/libs/frontend/src/lib/components/nav/navigation/navigation.svelte index 390197fd..53102419 100644 --- a/libs/frontend/src/lib/components/nav/navigation/navigation.svelte +++ b/libs/frontend/src/lib/components/nav/navigation/navigation.svelte @@ -3,15 +3,30 @@ import ToggleTheme from "../toggle-theme.svelte"; import UserDropdown from "../user-dropdown.svelte"; import NavigationItem from "./navigation-item.svelte"; - import { isAuthenticated, isDarkTheme, toggleDarkTheme } from "@/stores"; + import { isDarkTheme, toggleDarkTheme } from "@/stores/theme.store"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; - import { authenticatedUserInfo } from "@/stores"; + import { isAuthenticated, authenticatedUserInfo } from "@/stores/auth.store"; import Menu from "@lucide/svelte/icons/menu"; import Moon from "@lucide/svelte/icons/moon"; import Sun from "@lucide/svelte/icons/sun"; import { testIds } from "types"; + import { logger } from "$lib/utils/debug-logger"; const version = import.meta.env.VITE_APP_VERSION; + + // Log navigation state changes + $effect(() => { + logger.nav("Navigation rendering with auth state", { + isAuthenticated: $isAuthenticated, + userInfo: $authenticatedUserInfo + ? { + userId: $authenticatedUserInfo.userId, + username: $authenticatedUserInfo.username, + isAuthenticated: $authenticatedUserInfo.isAuthenticated + } + : null + }); + });
@@ -108,7 +123,7 @@ {/snippet} - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated && $authenticatedUserInfo} {@const profileLink = frontendUrls.userProfileByUsername( $authenticatedUserInfo.username )} @@ -122,7 +137,7 @@ - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated} {#snippet child(props)} Settings @@ -139,7 +154,7 @@ - {#if $authenticatedUserInfo?.isAuthenticated} + {#if $isAuthenticated} {#snippet child(props)} Log out diff --git a/libs/frontend/src/lib/components/nav/toggle-theme.svelte b/libs/frontend/src/lib/components/nav/toggle-theme.svelte index e48f07dd..75383322 100644 --- a/libs/frontend/src/lib/components/nav/toggle-theme.svelte +++ b/libs/frontend/src/lib/components/nav/toggle-theme.svelte @@ -1,5 +1,5 @@ -{#if $authenticatedUserInfo?.isAuthenticated} +{#if $isAuthenticated && $authenticatedUserInfo} {#snippet child({ props: avatarProps })} diff --git a/libs/frontend/src/lib/components/typography/markdown.svelte b/libs/frontend/src/lib/components/typography/markdown.svelte index 02913b65..3188b838 100644 --- a/libs/frontend/src/lib/components/typography/markdown.svelte +++ b/libs/frontend/src/lib/components/typography/markdown.svelte @@ -6,7 +6,7 @@ fallbackText = "no fallback provided", markdown = undefined }: { - markdown?: string | undefined; + markdown?: string | null | undefined; fallbackText?: string; } = $props(); @@ -17,7 +17,7 @@ }; -{#if markdown !== undefined} +{#if markdown !== undefined && markdown !== null} {#await parseMarkdown(markdown)}

Loading...

{:then parsedMarkdown} diff --git a/libs/frontend/src/lib/components/ui/alert/index.ts b/libs/frontend/src/lib/components/ui/alert/index.ts index 84571318..a66ef40f 100644 --- a/libs/frontend/src/lib/components/ui/alert/index.ts +++ b/libs/frontend/src/lib/components/ui/alert/index.ts @@ -1,8 +1,8 @@ import { type VariantProps, tv } from "tailwind-variants/lite"; -import Root from "./alert.svelte"; import Description from "./alert-description.svelte"; import Title from "./alert-title.svelte"; +import Root from "./alert.svelte"; export const alertVariants = tv({ base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4", @@ -26,11 +26,11 @@ export type Variant = VariantProps["variant"]; export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; export { - Root, - Description, - Title, // Root as Alert, Description as AlertDescription, - Title as AlertTitle + Title as AlertTitle, + Description, + Root, + Title }; diff --git a/libs/frontend/src/lib/components/ui/avatar/index.ts b/libs/frontend/src/lib/components/ui/avatar/index.ts index b08c7803..c265c5d8 100644 --- a/libs/frontend/src/lib/components/ui/avatar/index.ts +++ b/libs/frontend/src/lib/components/ui/avatar/index.ts @@ -1,13 +1,13 @@ -import Root from "./avatar.svelte"; -import Image from "./avatar-image.svelte"; import Fallback from "./avatar-fallback.svelte"; +import Image from "./avatar-image.svelte"; +import Root from "./avatar.svelte"; export { - Root, - Image, - Fallback, // Root as Avatar, + Fallback as AvatarFallback, Image as AvatarImage, - Fallback as AvatarFallback + Fallback, + Image, + Root }; diff --git a/libs/frontend/src/lib/components/ui/badge/index.ts b/libs/frontend/src/lib/components/ui/badge/index.ts index 64e0aa9b..c35b4f37 100644 --- a/libs/frontend/src/lib/components/ui/badge/index.ts +++ b/libs/frontend/src/lib/components/ui/badge/index.ts @@ -1,2 +1,5 @@ -export { default as Badge } from "./badge.svelte"; -export { badgeVariants, type BadgeVariant } from "./badge.svelte"; +export { + default as Badge, + badgeVariants, + type BadgeVariant +} from "./badge.svelte"; diff --git a/libs/frontend/src/lib/components/ui/breadcrumb/index.ts b/libs/frontend/src/lib/components/ui/breadcrumb/index.ts index 26519567..77917ca4 100644 --- a/libs/frontend/src/lib/components/ui/breadcrumb/index.ts +++ b/libs/frontend/src/lib/components/ui/breadcrumb/index.ts @@ -1,25 +1,25 @@ -import Root from "./breadcrumb.svelte"; import Ellipsis from "./breadcrumb-ellipsis.svelte"; import Item from "./breadcrumb-item.svelte"; -import Separator from "./breadcrumb-separator.svelte"; import Link from "./breadcrumb-link.svelte"; import List from "./breadcrumb-list.svelte"; import Page from "./breadcrumb-page.svelte"; +import Separator from "./breadcrumb-separator.svelte"; +import Root from "./breadcrumb.svelte"; export { - Root, - Ellipsis, - Item, - Separator, - Link, - List, - Page, // Root as Breadcrumb, Ellipsis as BreadcrumbEllipsis, Item as BreadcrumbItem, - Separator as BreadcrumbSeparator, Link as BreadcrumbLink, List as BreadcrumbList, - Page as BreadcrumbPage + Page as BreadcrumbPage, + Separator as BreadcrumbSeparator, + Ellipsis, + Item, + Link, + List, + Page, + Root, + Separator }; diff --git a/libs/frontend/src/lib/components/ui/button-group/index.ts b/libs/frontend/src/lib/components/ui/button-group/index.ts index 476bef81..b5458f0f 100644 --- a/libs/frontend/src/lib/components/ui/button-group/index.ts +++ b/libs/frontend/src/lib/components/ui/button-group/index.ts @@ -1,13 +1,13 @@ -import Root from "./button-group.svelte"; -import Text from "./button-group-text.svelte"; import Separator from "./button-group-separator.svelte"; +import Text from "./button-group-text.svelte"; +import Root from "./button-group.svelte"; export { - Root, - Text, - Separator, // Root as ButtonGroup, + Separator as ButtonGroupSeparator, Text as ButtonGroupText, - Separator as ButtonGroupSeparator + Root, + Separator, + Text }; diff --git a/libs/frontend/src/lib/components/ui/button/index.ts b/libs/frontend/src/lib/components/ui/button/index.ts index 068bfa26..2eb9e3eb 100644 --- a/libs/frontend/src/lib/components/ui/button/index.ts +++ b/libs/frontend/src/lib/components/ui/button/index.ts @@ -6,12 +6,12 @@ import Root, { } from "./button.svelte"; export { - Root, - type ButtonProps as Props, // Root as Button, buttonVariants, + Root, type ButtonProps, type ButtonSize, - type ButtonVariant + type ButtonVariant, + type ButtonProps as Props }; diff --git a/libs/frontend/src/lib/components/ui/card/index.ts b/libs/frontend/src/lib/components/ui/card/index.ts index d821ceb2..c20f9c94 100644 --- a/libs/frontend/src/lib/components/ui/card/index.ts +++ b/libs/frontend/src/lib/components/ui/card/index.ts @@ -1,22 +1,22 @@ -import Root from "./card.svelte"; import Content from "./card-content.svelte"; import Description from "./card-description.svelte"; import Footer from "./card-footer.svelte"; import Header from "./card-header.svelte"; import Title from "./card-title.svelte"; +import Root from "./card.svelte"; export { - Root, - Content, - Description, - Footer, - Header, - Title, // Root as Card, Content as CardContent, Description as CardDescription, Footer as CardFooter, Header as CardHeader, - Title as CardTitle + Title as CardTitle, + Content, + Description, + Footer, + Header, + Root, + Title }; diff --git a/libs/frontend/src/lib/components/ui/checkbox/index.ts b/libs/frontend/src/lib/components/ui/checkbox/index.ts index 5fba5a4d..85473cd8 100644 --- a/libs/frontend/src/lib/components/ui/checkbox/index.ts +++ b/libs/frontend/src/lib/components/ui/checkbox/index.ts @@ -1,6 +1,6 @@ import Root from "./checkbox.svelte"; export { - Root, // - Root as Checkbox + Root as Checkbox, + Root }; diff --git a/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte b/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte index 72cb72c9..020aeda8 100644 --- a/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte +++ b/libs/frontend/src/lib/components/ui/countdown-timer/countdown-timer.svelte @@ -1,7 +1,7 @@ diff --git a/libs/frontend/src/lib/components/ui/table/index.ts b/libs/frontend/src/lib/components/ui/table/index.ts index 450c9b33..adbc1c0c 100644 --- a/libs/frontend/src/lib/components/ui/table/index.ts +++ b/libs/frontend/src/lib/components/ui/table/index.ts @@ -1,4 +1,3 @@ -import Root from "./table.svelte"; import Body from "./table-body.svelte"; import Caption from "./table-caption.svelte"; import Cell from "./table-cell.svelte"; @@ -6,15 +5,16 @@ import Footer from "./table-footer.svelte"; import Head from "./table-head.svelte"; import Header from "./table-header.svelte"; import Row from "./table-row.svelte"; +import Root from "./table.svelte"; export { - Root, Body, Caption, Cell, Footer, Head, Header, + Root, Row, // Root as Table, diff --git a/libs/frontend/src/lib/components/ui/tabs/index.ts b/libs/frontend/src/lib/components/ui/tabs/index.ts index 968804cc..45450e34 100644 --- a/libs/frontend/src/lib/components/ui/tabs/index.ts +++ b/libs/frontend/src/lib/components/ui/tabs/index.ts @@ -6,13 +6,13 @@ import Trigger from "./tabs-trigger.svelte"; const Root = TabsPrimitive.Root; export { - Root, Content, List, - Trigger, + Root, // Root as Tabs, Content as TabsContent, List as TabsList, - Trigger as TabsTrigger + Trigger as TabsTrigger, + Trigger }; diff --git a/libs/frontend/src/lib/components/websocket/connection-status.svelte b/libs/frontend/src/lib/components/websocket/connection-status.svelte index f3b2eddd..527afdf5 100644 --- a/libs/frontend/src/lib/components/websocket/connection-status.svelte +++ b/libs/frontend/src/lib/components/websocket/connection-status.svelte @@ -12,7 +12,7 @@ class: className = "", showLabel = false }: { - wsManager: WebSocketManager; + wsManager: WebSocketManager; state: WebSocketState; class?: string; showLabel?: boolean; diff --git a/libs/frontend/src/lib/config/external-links.ts b/libs/frontend/src/lib/config/external-links.ts new file mode 100644 index 00000000..6fcbc60d --- /dev/null +++ b/libs/frontend/src/lib/config/external-links.ts @@ -0,0 +1,14 @@ +/** + * External resources and links + */ + +export const githubRepo = { + owner: "JuiceMitApfelnDrin", + name: "CodinCod", + url: "https://github.com/JuiceMitApfelnDrin/CodinCod" +} as const; + +export const twitchChannel = { + username: "your_twitch_username", + url: "https://twitch.tv/your_twitch_username" +} as const; diff --git a/libs/frontend/src/lib/config/frontend-urls.ts b/libs/frontend/src/lib/config/frontend-urls.ts new file mode 100644 index 00000000..b606e016 --- /dev/null +++ b/libs/frontend/src/lib/config/frontend-urls.ts @@ -0,0 +1,46 @@ +/** + * Frontend URL configuration + * Centralizes all frontend route paths + */ + +export const frontendUrls = { + HOME: "/", + LOGIN: "/login", + REGISTER: "/register", + LOGOUT: "/logout", + FORGOT_PASSWORD: "/forgot-password", + RESET_PASSWORD: "/reset-password", + + // Puzzles + PUZZLES: "/puzzles", + PUZZLE_CREATE: "/puzzles/create", + PUZZLE_DETAIL: (id: string) => `/puzzles/${id}`, + PUZZLE_EDIT: (id: string) => `/puzzles/${id}/edit`, + PUZZLE_PLAY: (id: string) => `/puzzles/${id}/play`, + + // Multiplayer + MULTIPLAYER: "/multiplayer", + MULTIPLAYER_GAME: (id: string) => `/multiplayer/${id}`, + + // Profile + PROFILE: (username: string) => `/profile/${username}`, + PROFILE_PUZZLES: (username: string) => `/profile/${username}/puzzles`, + + // Settings + SETTINGS: "/settings", + SETTINGS_PROFILE: "/settings/profile", + SETTINGS_ACCOUNT: "/settings/account", + SETTINGS_APPEARANCE: "/settings/appearance", + SETTINGS_PREFERENCES: "/settings/preferences", + SETTINGS_COMMUNITY: "/settings/community", + SETTINGS_NOTIFICATIONS: "/settings/notifications", + + // Other + LEADERBOARDS: "/leaderboards", + LEARN: "/learn", + DOCS: "/docs", + MODERATION: "/moderation", + MAINTENANCE: "/maintenance" +} as const; + +export type FrontendUrl = typeof frontendUrls; diff --git a/libs/frontend/src/lib/config/test-ids.ts b/libs/frontend/src/lib/config/test-ids.ts new file mode 100644 index 00000000..9e74a006 --- /dev/null +++ b/libs/frontend/src/lib/config/test-ids.ts @@ -0,0 +1,81 @@ +/** + * Test IDs for E2E testing + * Centralizes data-testid attributes used in tests + */ + +export const testIds = { + // Authentication + LOGIN_FORM: "login-form", + LOGIN_EMAIL: "login-email", + LOGIN_PASSWORD: "login-password", + LOGIN_SUBMIT: "login-submit", + REGISTER_FORM: "register-form", + REGISTER_USERNAME: "register-username", + REGISTER_EMAIL: "register-email", + REGISTER_PASSWORD: "register-password", + REGISTER_PASSWORD_CONFIRM: "register-password-confirm", + REGISTER_SUBMIT: "register-submit", + LOGOUT_BUTTON: "logout-button", + + // Navigation + NAV_HOME: "nav-home", + NAV_PUZZLES: "nav-puzzles", + NAV_MULTIPLAYER: "nav-multiplayer", + NAV_LEADERBOARDS: "nav-leaderboards", + NAV_PROFILE: "nav-profile", + NAV_SETTINGS: "nav-settings", + + // Puzzles + PUZZLE_LIST: "puzzle-list", + PUZZLE_CARD: "puzzle-card", + PUZZLE_CREATE_BUTTON: "puzzle-create-button", + PUZZLE_CREATE_FORM: "puzzle-create-form", + PUZZLE_EDIT_FORM: "puzzle-edit-form", + PUZZLE_DELETE_BUTTON: "puzzle-delete-button", + PUZZLE_DELETE_CONFIRM: "puzzle-delete-confirm", + PUZZLE_TITLE: "puzzle-title", + PUZZLE_STATEMENT: "puzzle-statement", + PUZZLE_DIFFICULTY: "puzzle-difficulty", + PUZZLE_VISIBILITY: "puzzle-visibility", + PUZZLE_SUBMIT_CODE: "puzzle-submit-code", + PUZZLE_RUN_CODE: "puzzle-run-code", + PUZZLE_CODE_EDITOR: "puzzle-code-editor", + PUZZLE_OUTPUT: "puzzle-output", + PUZZLE_TEST_RESULTS: "puzzle-test-results", + + // Multiplayer + MULTIPLAYER_CREATE: "multiplayer-create", + MULTIPLAYER_JOIN: "multiplayer-join", + MULTIPLAYER_GAME: "multiplayer-game", + MULTIPLAYER_CHAT: "multiplayer-chat", + MULTIPLAYER_STANDINGS: "multiplayer-standings", + + // Profile + PROFILE_INFO: "profile-info", + PROFILE_PUZZLES: "profile-puzzles", + PROFILE_ACTIVITY: "profile-activity", + PROFILE_STATS: "profile-stats", + PROFILE_EDIT_BUTTON: "profile-edit-button", + + // Settings + SETTINGS_PROFILE_FORM: "settings-profile-form", + SETTINGS_ACCOUNT_FORM: "settings-account-form", + SETTINGS_APPEARANCE_FORM: "settings-appearance-form", + SETTINGS_PREFERENCES_FORM: "settings-preferences-form", + + // Moderation + MODERATION_REPORTS: "moderation-reports", + MODERATION_REVIEWS: "moderation-reviews", + MODERATION_BAN_USER: "moderation-ban-user", + + // Common + LOADING_SPINNER: "loading-spinner", + ERROR_MESSAGE: "error-message", + SUCCESS_MESSAGE: "success-message", + CONFIRM_DIALOG: "confirm-dialog", + CANCEL_BUTTON: "cancel-button", + SUBMIT_BUTTON: "submit-button", + SAVE_BUTTON: "save-button" +} as const; + +export type TestId = (typeof testIds)[keyof typeof testIds]; diff --git a/libs/frontend/src/lib/config/websocket.ts b/libs/frontend/src/lib/config/websocket.ts index 97164b81..9c8268f9 100644 --- a/libs/frontend/src/lib/config/websocket.ts +++ b/libs/frontend/src/lib/config/websocket.ts @@ -1,14 +1,14 @@ -import { ERROR_MESSAGES } from "types"; - export function buildWebSocketUrl(path: string): string { - const wsBaseUrl = import.meta.env.VITE_BACKEND_WEBSOCKET_MULTIPLAYER; + // Use the Elixir backend WebSocket endpoint + // In development: ws://localhost:4000/socket + // The backend URL without the protocol prefix + const backendUrl = + import.meta.env.VITE_ELIXIR_BACKEND_URL || "http://localhost:4000"; - if (!wsBaseUrl) { - throw new Error( - `${ERROR_MESSAGES.SERVER.INTERNAL_ERROR}: VITE_BACKEND_WEBSOCKET_MULTIPLAYER environment variable is not set` - ); - } + // Convert http(s) to ws(s) + const wsBaseUrl = backendUrl.replace(/^http/, "ws"); + // Phoenix WebSocket endpoint is at /socket const baseUrl = wsBaseUrl.endsWith("/") ? wsBaseUrl.slice(0, -1) : wsBaseUrl; const normalizedPath = path.startsWith("/") ? path : `/${path}`; diff --git a/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts b/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts index 40435b14..3cb64c4a 100644 --- a/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts +++ b/libs/frontend/src/lib/features/authentication/register/config/register-form-schema.ts @@ -1,8 +1,7 @@ -import { z } from "zod"; import { registerSchema } from "types"; +import { z } from "zod"; // import { fetchWithAuthenticationCookie } from "../../utils/fetch-with-authentication-cookie"; // import { buildBackendUrl } from "@/config/backend"; -import { backendUrls } from "types"; export const registerFormSchema = registerSchema; // TODO: fix this, doesn't go to backend right now, it does, but doesn't update the form diff --git a/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts b/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts index d876deaf..067a48dd 100644 --- a/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts +++ b/libs/frontend/src/lib/features/authentication/utils/fetch-with-authentication-cookie.ts @@ -1,5 +1,4 @@ import { defaultFetchOptions } from "@/config/default-fetch-options"; -import { environment } from "types"; export function getCookieHeader(request: Request): Record { const cookie = request.headers.get("cookie"); diff --git a/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts b/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts index 8ba3805e..fd9ce795 100644 --- a/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts +++ b/libs/frontend/src/lib/features/authentication/utils/get-authenticated-user-info.ts @@ -1,70 +1,87 @@ -import { httpRequestMethod, backendUrls, cookieKeys } from "types"; +import { logger } from "$lib/utils/debug-logger"; +import { codincodApiWebAccountControllerShow } from "@/api/generated/account/account"; import type { Cookies } from "@sveltejs/kit"; -import { buildBackendUrl } from "@/config/backend"; +import { cookieKeys } from "types"; +/** + * Verifies authentication status by checking with the backend + * @param cookies - SvelteKit cookies object + * @param eventFetch - SvelteKit's fetch function (for SSR) + * @returns Authentication info including user data if authenticated + */ export async function getAuthenticatedUserInfo( cookies: Cookies, eventFetch = fetch ) { - try { - const url = buildBackendUrl(backendUrls.ACCOUNT); + logger.auth("🔍 Checking authentication status..."); - // Get the token cookie to forward to the backend + try { + // Check if token cookie exists const token = cookies.get(cookieKeys.TOKEN); + logger.auth("Token cookie check", { + exists: !!token, + cookieKey: cookieKeys.TOKEN, + tokenPreview: token ? `${token.substring(0, 20)}...` : null + }); + if (!token) { + logger.auth("❌ No token found - user not authenticated"); return { isAuthenticated: false }; } - const headers: HeadersInit = { - "Content-Type": "application/json", - // Forward the cookie to the backend - Cookie: `${cookieKeys.TOKEN}=${token}` - }; - - const response = await eventFetch(url, { - method: httpRequestMethod.GET, - headers - }); - - if (!response.ok) { - if (response.status === 401) { - // Token is invalid, just return unauthenticated - return { - isAuthenticated: false - }; - } - console.error( - `Failed to verify authentication: ${response.status} ${response.statusText} from ${url}` - ); - const errorBody = await response - .text() - .catch(() => "Unable to read response body"); - console.error("Response body:", errorBody); - throw new Error( - `Failed to verify authentication: ${response.status} ${response.statusText}` - ); - } - - const authenticatedInfo = await response.json(); + // Use generated Orval endpoint with server-side fetch + logger.auth("Calling account endpoint to verify token..."); + const authenticatedInfo = await codincodApiWebAccountControllerShow({ + fetch: eventFetch + } as RequestInit); + logger.auth("✅ Account endpoint response received", authenticatedInfo); - if (authenticatedInfo.isAuthenticated) { + // The backend returns { isAuthenticated: true, userId, username, role } + if ( + authenticatedInfo && + typeof authenticatedInfo === "object" && + "isAuthenticated" in authenticatedInfo + ) { + logger.auth("✅ User authenticated successfully", { + userId: authenticatedInfo.userId, + username: authenticatedInfo.username, + role: authenticatedInfo.role, + isAuthenticated: authenticatedInfo.isAuthenticated + }); return authenticatedInfo; } + + logger.auth( + "⚠️ Invalid response format from account endpoint - treating as not authenticated" + ); + return { + isAuthenticated: false + }; } catch (err) { + // Handle 401 Unauthorized gracefully (invalid/expired token) + if (err instanceof Error && err.message.includes("401")) { + logger.auth("❌ 401 Unauthorized - token invalid or expired"); + return { + isAuthenticated: false + }; + } + + // Log other errors for debugging if (err instanceof Error) { - console.error("Error verifying authentication:", err.message); - if ("cause" in err) { - console.error("Error cause:", err.cause); - } + logger.error("Error verifying authentication", { + message: err.message, + name: err.name, + stack: err.stack + }); } else { - console.error("Error verifying authentication:", err); + logger.error("Error verifying authentication (unknown error)", err); } - } - return { - isAuthenticated: false - }; + return { + isAuthenticated: false + }; + } } diff --git a/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts b/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts new file mode 100644 index 00000000..f8c7ae69 --- /dev/null +++ b/libs/frontend/src/lib/features/authentication/utils/is-sveltekit-redirect.ts @@ -0,0 +1,34 @@ +/** + * Type guard to check if an error is a SvelteKit redirect + * + * SvelteKit's redirect() function throws an object with status and location properties. + * This helper detects those redirect errors so they can be re-thrown to allow + * SvelteKit's routing layer to handle them properly. + * + * @param error - The caught error to check + * @returns true if the error is a SvelteKit redirect (status 3xx with location) + * + * @example + * ```ts + * try { + * await doSomething(); + * throw redirect(302, '/dashboard'); + * } catch (error) { + * if (isSvelteKitRedirect(error)) { + * throw error; // Let SvelteKit handle the redirect + * } + * // Handle other errors + * } + * ``` + */ +export function isSvelteKitRedirect(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "status" in error && + "location" in error && + typeof error.status === "number" && + error.status >= 300 && + error.status < 400 + ); +} diff --git a/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts b/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts index ceea463a..cf7b23b3 100644 --- a/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts +++ b/libs/frontend/src/lib/features/authentication/utils/set-cookie.ts @@ -1,5 +1,5 @@ -import type { Cookies } from "@sveltejs/kit"; import { env } from "$env/dynamic/private"; +import type { Cookies } from "@sveltejs/kit"; import { environment, getCookieOptions } from "types"; export function setCookie(result: Response, cookies: Cookies) { diff --git a/libs/frontend/src/lib/features/chat/components/chat-message.svelte b/libs/frontend/src/lib/features/chat/components/chat-message.svelte index 80e0aa2f..082a3f19 100644 --- a/libs/frontend/src/lib/features/chat/components/chat-message.svelte +++ b/libs/frontend/src/lib/features/chat/components/chat-message.svelte @@ -1,6 +1,6 @@ diff --git a/libs/frontend/src/lib/features/comment/components/comment.svelte b/libs/frontend/src/lib/features/comment/components/comment.svelte index 1adf9dbc..703bb72f 100644 --- a/libs/frontend/src/lib/features/comment/components/comment.svelte +++ b/libs/frontend/src/lib/features/comment/components/comment.svelte @@ -2,12 +2,9 @@ import { commentTypeEnum, getUserIdFromUser, - httpRequestMethod, isAuthor, isCommentDto, - voteTypeEnum, type CommentDto, - type CommentVoteRequest, type ObjectId } from "types"; import CommentMetaInfo from "./comment-meta-info.svelte"; @@ -21,10 +18,14 @@ import MessageCircle from "@lucide/svelte/icons/message-circle"; import MessageCircleOff from "@lucide/svelte/icons/message-circle-off"; import Trash from "@lucide/svelte/icons/trash"; - import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; - import { buildBackendUrl } from "@/config/backend"; - import { backendUrls } from "types"; - import { authenticatedUserInfo, isAuthenticated } from "@/stores"; + import { + codincodApiWebCommentControllerVote, + codincodApiWebCommentControllerShow, + codincodApiWebCommentControllerDelete + } from "@/api/generated/default/default"; + import { VoteRequestType } from "@/api/generated/schemas/voteRequestType"; + import type { VoteRequest } from "@/api/generated/schemas/voteRequest"; + import { authenticatedUserInfo, isAuthenticated } from "@/stores/auth.store"; import * as DropdownMenu from "@/components/ui/dropdown-menu"; import { testIds } from "types"; @@ -38,17 +39,12 @@ let isReplying: boolean = $state(false); - async function handleVote(commentVoteRequest: CommentVoteRequest) { - const response = await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentByIdVote(comment._id)), - { - body: JSON.stringify(commentVoteRequest), - method: httpRequestMethod.POST - } + async function handleVote(commentVoteRequest: VoteRequest) { + const updatedComment = await codincodApiWebCommentControllerVote( + comment._id, + commentVoteRequest ); - const updatedComment = await response.json(); - if (isCommentDto(updatedComment)) { comment = { ...comment, @@ -59,7 +55,7 @@ } function onCommentAdded(newComment: CommentDto) { - const newComments = [...(comment.comments ?? []), newComment] as any[]; // unfortunately needed because recursive types are hard + const newComments = [...(comment.comments ?? []), newComment._id]; comment = { ...comment, comments: newComments @@ -69,19 +65,13 @@ } async function fetchReplies() { - const response = await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(comment._id)), - { - method: httpRequestMethod.GET - } - ); - - const updatedCommentInfoWithSubComments = await response.json(); + const updatedCommentInfoWithSubComments = + await codincodApiWebCommentControllerShow(comment._id); if (isCommentDto(updatedCommentInfoWithSubComments)) { comment = { ...comment, - comments: [...(updatedCommentInfoWithSubComments.comments ?? [])], + comments: updatedCommentInfoWithSubComments.comments ?? [], downvote: updatedCommentInfoWithSubComments.downvote, text: updatedCommentInfoWithSubComments.text, updatedAt: updatedCommentInfoWithSubComments.updatedAt, @@ -91,13 +81,7 @@ } async function deleteComment() { - await fetchWithAuthenticationCookie( - buildBackendUrl(backendUrls.commentById(comment._id)), - { - method: httpRequestMethod.DELETE - } - ); - + await codincodApiWebCommentControllerDelete(comment._id); onDeleted(comment._id); } @@ -154,7 +138,7 @@ data-testid={testIds.COMMENT_COMPONENT_BUTTON_UPVOTE_COMMENT} variant="outline" onclick={() => { - handleVote({ type: voteTypeEnum.UPVOTE }); + handleVote({ type: VoteRequestType.upvote }); }} > @@ -164,7 +148,7 @@ data-testid={testIds.COMMENT_COMPONENT_BUTTON_DOWNVOTE_COMMENT} variant="outline" onclick={() => { - handleVote({ type: voteTypeEnum.DOWNVOTE }); + handleVote({ type: VoteRequestType.downvote }); }} > diff --git a/libs/frontend/src/lib/features/game/components/codemirror.svelte b/libs/frontend/src/lib/features/game/components/codemirror.svelte index 047976bf..e3802647 100644 --- a/libs/frontend/src/lib/features/game/components/codemirror.svelte +++ b/libs/frontend/src/lib/features/game/components/codemirror.svelte @@ -1,7 +1,8 @@ {#if puzzle}

- {puzzle.title} + {puzzle.title ?? "Untitled Puzzle"}

- {#if isUserDto(puzzle.author)} + {#if authorUsername}
Created by
- +
{/if} -
Created on
-
- {formattedDateYearMonthDay(puzzle.createdAt)} -
+ {#if puzzle.createdAt} +
Created on
+
+ {formattedDateYearMonthDay(puzzle.createdAt as string | Date)} +
+ {/if} - {#if hasBeenUpdated} + {#if hasBeenUpdated && puzzle.updatedAt}
Updated on
- {formattedDateYearMonthDay(puzzle.createdAt)} + {formattedDateYearMonthDay(puzzle.updatedAt as string | Date)}
{/if}
diff --git a/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte b/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte index 9cdd45ab..0366d691 100644 --- a/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte +++ b/libs/frontend/src/lib/features/puzzles/components/user-hover-card.svelte @@ -4,9 +4,7 @@ import * as Avatar from "$lib/components/ui/avatar"; import { frontendUrls, isUserDto, type UserDto } from "types"; import Calendar from "@lucide/svelte/icons/calendar"; - import { buildBackendUrl } from "@/config/backend"; - import { backendUrls } from "types"; - import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; + import { codincodApiWebUserControllerShow } from "@/api/generated/user/user"; import type { Button as ButtonPrimitive } from "bits-ui"; import dayjs from "dayjs"; import { cn } from "@/utils/cn"; @@ -29,15 +27,10 @@ return userInfoCache[username]; } - let url = buildBackendUrl(backendUrls.userByUsername(username)); + const response = await codincodApiWebUserControllerShow(username); + userInfoCache[username] = response as UserDto; - const response = await fetchWithAuthenticationCookie(url).then((res) => - res.json() - ); - - userInfoCache[username] = response; - - return response; + return response as UserDto; } @@ -56,7 +49,7 @@ {#await fetchUserInfo(username)} loading... - {:then { user }} + {:then user} {#if isUserDto(user)}
diff --git a/libs/frontend/src/lib/stores/auth.store.ts b/libs/frontend/src/lib/stores/auth.store.ts new file mode 100644 index 00000000..b3ba7094 --- /dev/null +++ b/libs/frontend/src/lib/stores/auth.store.ts @@ -0,0 +1,79 @@ +import { browser } from "$app/environment"; +import { logger } from "$lib/utils/debug-logger"; +import { derived, writable } from "svelte/store"; +import type { AuthenticatedInfo } from "types"; + +/** + * Store for authenticated user information + * Contains user ID, username, role, and authentication status + */ +export const authenticatedUserInfo = writable(null); + +/** + * Derived store that returns true if user is authenticated + */ +export const isAuthenticated = derived(authenticatedUserInfo, (userInfo) => { + const authenticated = userInfo?.isAuthenticated ?? false; + + logger.store("isAuthenticated derived update", { + authenticated, + userInfo: userInfo + ? { + userId: userInfo.userId, + username: userInfo.username, + isAuthenticated: userInfo.isAuthenticated + } + : null + }); + + return authenticated; +}); + +/** + * Update authenticated user information + */ +export function setAuthenticatedUser(userInfo: AuthenticatedInfo | null) { + authenticatedUserInfo.set(userInfo); +} + +/** + * Clear authenticated user information (logout) + */ +export function clearAuthenticatedUser() { + authenticatedUserInfo.set(null); +} + +/** + * Check if user has a specific role + */ +export const hasRole = (role: string) => + derived(authenticatedUserInfo, (userInfo) => userInfo?.role === role); + +/** + * Get current user ID + */ +export const currentUserId = derived( + authenticatedUserInfo, + (userInfo) => userInfo?.userId +); + +/** + * Get current username + */ +export const currentUsername = derived( + authenticatedUserInfo, + (userInfo) => userInfo?.username +); + +// Debug logging in development +if (browser) { + authenticatedUserInfo.subscribe((value) => { + logger.store("authenticatedUserInfo changed", { + isAuthenticated: value?.isAuthenticated ?? false, + userId: value?.userId, + username: value?.username, + role: value?.role, + fullValue: value + }); + }); +} diff --git a/libs/frontend/src/lib/stores/current-time.ts b/libs/frontend/src/lib/stores/current-time.store.ts similarity index 100% rename from libs/frontend/src/lib/stores/current-time.ts rename to libs/frontend/src/lib/stores/current-time.store.ts diff --git a/libs/frontend/src/lib/stores/languages.store.ts b/libs/frontend/src/lib/stores/languages.store.ts new file mode 100644 index 00000000..d4c5cc1c --- /dev/null +++ b/libs/frontend/src/lib/stores/languages.store.ts @@ -0,0 +1,187 @@ +import { browser } from "$app/environment"; +import { codincodApiWebProgrammingLanguageControllerIndex } from "@/api/generated/default/default"; +import { localStorageKeys } from "@/config/local-storage"; +import { logger } from "@/utils/debug-logger"; +import { get, writable } from "svelte/store"; +import { type ProgrammingLanguageDto } from "types"; +import { isAuthenticated } from "./auth.store"; + +const CACHE_DURATION_MS = 1_000 * 60 * 60; +const RETRY_DELAY_MS = 5_000; +const MAX_RETRIES = 3; + +interface LanguagesCache { + languages: ProgrammingLanguageDto[]; + timestamp: number; +} + +const createLanguagesStore = () => { + const { set, subscribe } = writable([]); + let retryCount = 0; + let retryTimeout: ReturnType | null = null; + + // Helper to safely parse cached data + const getCachedLanguages = (): ProgrammingLanguageDto[] | null => { + if (!browser) { + logger.store("Languages cache check skipped (not in browser)"); + return null; + } + + try { + const cached = localStorage.getItem(localStorageKeys.LANGUAGES); + if (!cached) { + logger.store("No cached languages found"); + return null; + } + + const parsed: LanguagesCache = JSON.parse(cached); + const now = Date.now(); + + // Check if cache is still valid + if ( + parsed.timestamp && + parsed.languages && + Array.isArray(parsed.languages) + ) { + const age = now - parsed.timestamp; + if (age < CACHE_DURATION_MS) { + logger.store( + `Using cached languages (${parsed.languages.length} items, age: ${Math.round(age / 1000)}s)` + ); + return parsed.languages; + } else { + logger.store( + `Cached languages expired (age: ${Math.round(age / 1000)}s > ${CACHE_DURATION_MS / 1000}s)` + ); + } + } + } catch (error) { + logger.error("Failed to parse cached languages", error); + // Clear invalid cache + localStorage.removeItem(localStorageKeys.LANGUAGES); + } + return null; + }; + + // Helper to save to cache + const saveToCache = (languages: ProgrammingLanguageDto[]): void => { + if (!browser) return; + + try { + const cache: LanguagesCache = { + languages, + timestamp: Date.now() + }; + localStorage.setItem(localStorageKeys.LANGUAGES, JSON.stringify(cache)); + logger.store(`Cached ${languages.length} languages to localStorage`); + } catch (error) { + logger.error("Failed to save languages to cache", error); + } + }; + + const fetchLanguages = async (): Promise => { + if (!browser) { + logger.store("Languages fetch skipped (not in browser)"); + return; + } + + // Check if user is authenticated before fetching + const authenticated = get(isAuthenticated); + if (!authenticated) { + logger.store("Languages fetch skipped (user not authenticated)"); + return; + } + + try { + logger.store("Fetching languages from API..."); + const languagesArray = + await codincodApiWebProgrammingLanguageControllerIndex(); + + logger.store("Raw languages response:", languagesArray); + + if (!Array.isArray(languagesArray) || languagesArray.length === 0) { + logger.error("Languages array is empty or invalid:", languagesArray); + throw new Error("Languages array is empty or invalid"); + } + + logger.store(`Successfully loaded ${languagesArray.length} languages`); + set(languagesArray as ProgrammingLanguageDto[]); + saveToCache(languagesArray as ProgrammingLanguageDto[]); + retryCount = 0; // Reset retry count on success + } catch (error) { + logger.error("Failed to load languages", error); + + // Retry logic + if (retryCount < MAX_RETRIES) { + retryCount++; + logger.store( + `Retrying language fetch (${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY_MS}ms...` + ); + + retryTimeout = setTimeout(() => { + fetchLanguages(); + }, RETRY_DELAY_MS); + } else { + logger.error("Max retries reached for loading languages"); + // Still keep cached data if available + } + } + }; + + return { + async loadLanguages() { + if (!browser) { + logger.store("loadLanguages() skipped (not in browser)"); + return; + } + + logger.store("loadLanguages() called"); + + // Try to load from cache first + const cached = getCachedLanguages(); + if (cached && cached.length > 0) { + logger.store(`Setting ${cached.length} cached languages to store`); + set(cached); + // Refresh in background + logger.store("Starting background refresh of languages"); + fetchLanguages().catch((error) => { + logger.error("Background refresh of languages failed", error); + }); + return; + } + + // No valid cache, fetch from server + logger.store("No valid cache, fetching languages from server"); + await fetchLanguages(); + }, + + async refreshLanguages() { + logger.store("refreshLanguages() called - forcing fresh fetch"); + await fetchLanguages(); + }, + + clearRetryTimeout() { + if (retryTimeout) { + logger.store("Clearing languages retry timeout"); + clearTimeout(retryTimeout); + retryTimeout = null; + } + }, + + subscribe + }; +}; + +export const languages = createLanguagesStore(); + +// Auto-load languages when user logs in +if (browser) { + isAuthenticated.subscribe((authenticated) => { + if (authenticated) { + logger.store("User authenticated - auto-loading languages"); + languages.loadLanguages(); + } else { + logger.store("User not authenticated - skipping language load"); + } + }); +} diff --git a/libs/frontend/src/lib/stores/languages.ts b/libs/frontend/src/lib/stores/languages.ts deleted file mode 100644 index 3114d8b3..00000000 --- a/libs/frontend/src/lib/stores/languages.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { browser } from "$app/environment"; -import { localStorageKeys } from "@/config/local-storage"; -import { buildBackendUrl } from "@/config/backend"; -import { writable } from "svelte/store"; -import { - backendUrls, - httpRequestMethod, - type ProgrammingLanguageDto -} from "types"; - -const CACHE_DURATION_MS = 1000 * 60 * 60; // 1 hour -const RETRY_DELAY_MS = 5000; // 5 seconds -const MAX_RETRIES = 3; - -interface LanguagesCache { - languages: ProgrammingLanguageDto[]; - timestamp: number; -} - -const createLanguagesStore = () => { - const { set, subscribe } = writable([]); - let retryCount = 0; - let retryTimeout: ReturnType | null = null; - - // Helper to safely parse cached data - const getCachedLanguages = (): ProgrammingLanguageDto[] | null => { - if (!browser) return null; - - try { - const cached = localStorage.getItem(localStorageKeys.LANGUAGES); - if (!cached) return null; - - const parsed: LanguagesCache = JSON.parse(cached); - const now = Date.now(); - - // Check if cache is still valid - if ( - parsed.timestamp && - parsed.languages && - Array.isArray(parsed.languages) - ) { - if (now - parsed.timestamp < CACHE_DURATION_MS) { - return parsed.languages; - } - } - } catch (error) { - console.error("Failed to parse cached languages:", error); - // Clear invalid cache - localStorage.removeItem(localStorageKeys.LANGUAGES); - } - return null; - }; - - // Helper to save to cache - const saveToCache = (languages: ProgrammingLanguageDto[]): void => { - if (!browser) return; - - try { - const cache: LanguagesCache = { - languages, - timestamp: Date.now() - }; - localStorage.setItem(localStorageKeys.LANGUAGES, JSON.stringify(cache)); - } catch (error) { - console.error("Failed to save languages to cache:", error); - } - }; - - const fetchLanguages = async (): Promise => { - if (!browser) return; - - try { - const response = await fetch( - buildBackendUrl(backendUrls.PROGRAMMING_LANGUAGE), - { - method: httpRequestMethod.GET, - headers: { - "Content-Type": "application/json" - } - } - ); - - if (!response.ok) { - throw new Error( - `Failed to fetch languages: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - - if (!data.languages || !Array.isArray(data.languages)) { - throw new Error("Invalid response format: expected { languages: [] }"); - } - - set(data.languages); - saveToCache(data.languages); - retryCount = 0; // Reset retry count on success - } catch (error) { - console.error("Failed to load languages:", error); - - // Retry logic - if (retryCount < MAX_RETRIES) { - retryCount++; - console.log( - `Retrying language fetch (${retryCount}/${MAX_RETRIES}) in ${RETRY_DELAY_MS}ms...` - ); - - retryTimeout = setTimeout(() => { - fetchLanguages(); - }, RETRY_DELAY_MS); - } else { - console.error("Max retries reached for loading languages"); - // Still keep cached data if available - } - } - }; - - return { - async loadLanguages() { - if (!browser) return; - - // Try to load from cache first - const cached = getCachedLanguages(); - if (cached && cached.length > 0) { - set(cached); - // Refresh in background - fetchLanguages().catch((error) => { - console.warn("Background refresh of languages failed:", error); - }); - return; - } - - // No valid cache, fetch from server - await fetchLanguages(); - }, - - async refreshLanguages() { - await fetchLanguages(); - }, - - clearRetryTimeout() { - if (retryTimeout) { - clearTimeout(retryTimeout); - retryTimeout = null; - } - }, - - subscribe - }; -}; - -export const languages = createLanguagesStore(); - -// Auto-load languages on initialization -if (browser) { - languages.loadLanguages(); -} diff --git a/libs/frontend/src/lib/stores/preferences.ts b/libs/frontend/src/lib/stores/preferences.store.ts similarity index 54% rename from libs/frontend/src/lib/stores/preferences.ts rename to libs/frontend/src/lib/stores/preferences.store.ts index 37ad1925..30225aa6 100644 --- a/libs/frontend/src/lib/stores/preferences.ts +++ b/libs/frontend/src/lib/stores/preferences.store.ts @@ -1,23 +1,24 @@ import { browser } from "$app/environment"; -import { buildBackendUrl } from "@/config/backend"; +import { + codincodApiWebAccountPreferenceControllerDelete, + codincodApiWebAccountPreferenceControllerReplace, + codincodApiWebAccountPreferenceControllerShow +} from "@/api/generated/account-preferences/account-preferences"; import { localStorageKeys } from "@/config/local-storage"; -import { fetchWithAuthenticationCookie } from "@/features/authentication/utils/fetch-with-authentication-cookie"; -import { writable } from "svelte/store"; +import { derived, writable } from "svelte/store"; import { - backendUrls, editorPreferencesSchema, - httpRequestMethod, - httpResponseCodes, isPreferencesDto, isThemeOption, - type PreferencesDto + type PreferencesDto, + type UpdatePreferencesRequest } from "types"; +import { isAuthenticated } from "./auth.store"; +import { theme } from "./theme.store"; const createPreferencesStore = () => { const { set, subscribe, update } = writable(null); - const url = buildBackendUrl(backendUrls.ACCOUNT_PREFERENCES); - // Helper to safely parse localStorage data const parseStoredPreferences = ( stored: string | null @@ -78,33 +79,30 @@ const createPreferencesStore = () => { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url); - - if (!response.ok) { - if (response.status === httpResponseCodes.CLIENT_ERROR.NOT_FOUND) { - // No preferences found, create default - const defaultPreferences: PreferencesDto = { - editor: editorPreferencesSchema.parse({}) - }; - - const theme = localStorage.getItem(localStorageKeys.THEME); - if (isThemeOption(theme)) { - defaultPreferences.theme = theme; - } + const data = await codincodApiWebAccountPreferenceControllerShow(); + + set(data as PreferencesDto); + saveToLocalStorage(data as PreferencesDto); + } catch (error: unknown) { + // Handle 404 - create default preferences + const is404 = + error instanceof Error && error.message?.includes?.("404"); + if (is404) { + // Use Dto type which doesn't require owner (server will set it from auth) + const defaultPreferences: PreferencesDto = { + editor: editorPreferencesSchema.parse({}) + }; - await this.updatePreferences(defaultPreferences); - return; + const theme = localStorage.getItem(localStorageKeys.THEME); + if (isThemeOption(theme)) { + defaultPreferences.theme = theme; } - throw new Error( - `Failed to fetch preferences: ${response.status} ${response.statusText}` - ); + // Update will create preferences on the server + await this.updatePreferences(defaultPreferences); + return; } - const data = await response.json(); - set(data); - saveToLocalStorage(data); - } catch (error) { console.error("Failed to load preferences:", error); // Keep existing state if refresh fails } @@ -114,13 +112,7 @@ const createPreferencesStore = () => { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url, { - method: httpRequestMethod.DELETE - }); - - if (!response.ok) { - throw new Error(`Failed to reset preferences: ${response.status}`); - } + await codincodApiWebAccountPreferenceControllerDelete(); set(null); saveToLocalStorage(null); @@ -132,20 +124,17 @@ const createPreferencesStore = () => { subscribe, - async updatePreferences(updates: Partial) { + async updatePreferences(updates: UpdatePreferencesRequest) { if (!browser) return; try { - const response = await fetchWithAuthenticationCookie(url, { - body: JSON.stringify(updates), - method: httpRequestMethod.PUT - }); - - if (!response.ok) { - throw new Error(`Failed to update preferences: ${response.status}`); - } + // Clean up undefined values to satisfy exactOptionalPropertyTypes + const cleanUpdates = Object.fromEntries( + Object.entries(updates).filter(([_, value]) => value !== undefined) + ); - const updatedData = await response.json(); + const updatedData = + await codincodApiWebAccountPreferenceControllerReplace(cleanUpdates); // Use the server response as source of truth update((current) => { @@ -156,7 +145,7 @@ const createPreferencesStore = () => { ...(current?.editor ?? editorPreferencesSchema.parse({})), ...(updatedData.editor ?? {}) } - }; + } as PreferencesDto; saveToLocalStorage(merged); return merged; }); @@ -169,3 +158,40 @@ const createPreferencesStore = () => { }; export const preferences = createPreferencesStore(); + +/** + * start integrate preferences store + */ + +if (browser) { + isAuthenticated.subscribe((isAuthenticated) => { + if (isAuthenticated) { + preferences.loadPreferences(); + } + }); + + preferences.subscribe((newPreferences) => { + if (newPreferences) { + localStorage.setItem( + localStorageKeys.PREFERENCES, + JSON.stringify(newPreferences) + ); + } + + if (newPreferences?.theme) { + theme.set(newPreferences.theme); + } + }); + + derived([theme, isAuthenticated], ([theme, isAuthenticated]) => { + return { isAuthenticated, theme }; + }).subscribe(({ isAuthenticated, theme }) => { + if (isAuthenticated) { + preferences.updatePreferences({ theme }); + } + }); +} + +/** + * end integrate preferences store + */ diff --git a/libs/frontend/src/lib/stores/index.ts b/libs/frontend/src/lib/stores/theme.store.ts similarity index 52% rename from libs/frontend/src/lib/stores/index.ts rename to libs/frontend/src/lib/stores/theme.store.ts index 46078af0..93db682a 100644 --- a/libs/frontend/src/lib/stores/index.ts +++ b/libs/frontend/src/lib/stores/theme.store.ts @@ -1,37 +1,56 @@ import { browser } from "$app/environment"; import { localStorageKeys } from "@/config/local-storage"; import { derived, writable } from "svelte/store"; -import { - isThemeOption, - themeOption, - type AuthenticatedInfo, - type ThemeOption -} from "types"; -import { preferences } from "./preferences"; +import { isThemeOption, themeOption, type ThemeOption } from "types"; -const theme = writable(); +/** + * Store for the current theme (light/dark mode) + */ +export const theme = writable(); + +/** + * Derived store that returns true if dark theme is active + */ export const isDarkTheme = derived( theme, (currentTheme) => currentTheme === themeOption.DARK ); + +/** + * Toggle between light and dark theme + */ export const toggleDarkTheme = () => theme.update((oldValue) => oldValue === themeOption.DARK ? themeOption.LIGHT : themeOption.DARK ); -if (browser) { +/** + * Initialize theme from localStorage or system preference + */ +function initializeTheme() { + if (!browser) return; + const prefersDarkTheme = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; + const storedTheme = localStorage.getItem(localStorageKeys.THEME); const preferredTheme = prefersDarkTheme ? themeOption.DARK : themeOption.LIGHT; + const currentThemeOption = isThemeOption(storedTheme) ? storedTheme : preferredTheme; theme.set(currentThemeOption); +} + +/** + * Sync theme changes to DOM and localStorage + */ +function syncTheme() { + if (!browser) return; theme.subscribe((newTheme) => { const isDarkClass = document.documentElement.classList.contains( @@ -49,49 +68,6 @@ if (browser) { }); } -export const authenticatedUserInfo = writable(null); - -export const isAuthenticated = derived(authenticatedUserInfo, (userInfo) => { - return userInfo?.isAuthenticated ?? false; -}); - -/** - * end user-info store - */ - -/** - * start integrate preferences store - */ - -if (browser) { - isAuthenticated.subscribe((isAuthenticated) => { - if (isAuthenticated) { - preferences.loadPreferences(); - } - }); - - preferences.subscribe((newPreferences) => { - if (newPreferences) { - localStorage.setItem( - localStorageKeys.PREFERENCES, - JSON.stringify(newPreferences) - ); - } - - if (newPreferences?.theme) { - theme.set(newPreferences.theme); - } - }); - - derived([theme, isAuthenticated], ([theme, isAuthenticated]) => { - return { isAuthenticated, theme }; - }).subscribe(({ isAuthenticated, theme }) => { - if (isAuthenticated) { - preferences.updatePreferences({ theme }); - } - }); -} - -/** - * end integrate preferences store - */ +// Initialize theme on module load +initializeTheme(); +syncTheme(); diff --git a/libs/frontend/src/lib/utils/debug-logger.ts b/libs/frontend/src/lib/utils/debug-logger.ts new file mode 100644 index 00000000..4ae0199c --- /dev/null +++ b/libs/frontend/src/lib/utils/debug-logger.ts @@ -0,0 +1,138 @@ +/** + * Development-only debug logger + * Provides consistent, colorful logging with timestamps and categories + */ + +import { dev } from "$app/environment"; + +type LogCategory = + | "AUTH" + | "API" + | "STORE" + | "NAVIGATION" + | "PAGE" + | "WEBSOCKET" + | "FORM" + | "ERROR"; + +interface LogStyle { + bg: string; + color: string; + icon: string; +} + +const styles: Record = { + AUTH: { bg: "#006342ff", color: "#fff", icon: "🔐" }, + API: { bg: "#002f7bff", color: "#fff", icon: "🌐" }, + STORE: { bg: "#25007bff", color: "#fff", icon: "📦" }, + NAVIGATION: { bg: "#744900ff", color: "#fff", icon: "🧭" }, + PAGE: { bg: "#670034ff", color: "#fff", icon: "📄" }, + WEBSOCKET: { bg: "#006577ff", color: "#fff", icon: "🔌" }, + FORM: { bg: "#416c00ff", color: "#fff", icon: "📝" }, + ERROR: { bg: "#7d0000ff", color: "#fff", icon: "❌" } +}; + +class DebugLogger { + private enabled: boolean; + + constructor() { + this.enabled = dev; + } + + private getTimestamp(): string { + const now = new Date(); + return now.toISOString().split("T")[1].split(".")[0]; + } + + private log( + category: LogCategory, + message: string, + data?: unknown, + isError = false + ): void { + if (!this.enabled) return; + + const style = styles[category]; + const timestamp = this.getTimestamp(); + const prefix = `%c${style.icon} ${category}%c [${timestamp}]`; + const prefixStyles = [ + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;`, + `color: #666; font-size: 0.9em;` + ]; + + if (isError) { + console.error(prefix, ...prefixStyles, message, data ?? ""); + } else if (data !== undefined) { + console.log(prefix, ...prefixStyles, message, data); + } else { + console.log(prefix, ...prefixStyles, message); + } + } + + // Authentication logs + auth(message: string, data?: unknown): void { + this.log("AUTH", message, data); + } + + // API request/response logs + api(message: string, data?: unknown): void { + this.log("API", message, data); + } + + // Store state changes + store(message: string, data?: unknown): void { + this.log("STORE", message, data); + } + + // Navigation events + nav(message: string, data?: unknown): void { + this.log("NAVIGATION", message, data); + } + + // Page lifecycle events + page(message: string, data?: unknown): void { + this.log("PAGE", message, data); + } + + // WebSocket events + ws(message: string, data?: unknown): void { + this.log("WEBSOCKET", message, data); + } + + // Form events + form(message: string, data?: unknown): void { + this.log("FORM", message, data); + } + + // Errors + error(message: string, error?: unknown): void { + this.log("ERROR", message, error, true); + } + + // Group related logs together + group(category: LogCategory, title: string, fn: () => void): void { + if (!this.enabled) return; + + const style = styles[category]; + console.group( + `%c${style.icon} ${category}: ${title}`, + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;` + ); + fn(); + console.groupEnd(); + } + + // Table format for structured data + table(category: LogCategory, title: string, data: unknown): void { + if (!this.enabled) return; + + const style = styles[category]; + console.log( + `%c${style.icon} ${category}: ${title}`, + `background: ${style.bg}; color: ${style.color}; padding: 2px 6px; border-radius: 3px; font-weight: bold;` + ); + console.table(data); + } +} + +export const logger = new DebugLogger(); diff --git a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts index c3a05229..b374f781 100644 --- a/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts +++ b/libs/frontend/src/lib/websocket/websocket-manager.svelte.ts @@ -8,10 +8,11 @@ * - Type-safe message handling */ +import { logger } from "@/utils/debug-logger"; import { websocketCloseCodes } from "types"; import { - WEBSOCKET_STATES, WEBSOCKET_RECONNECT, + WEBSOCKET_STATES, type WebSocketState } from "./websocket-constants"; @@ -59,6 +60,13 @@ export class WebSocketManager { options.maxReconnectAttempts ?? WEBSOCKET_RECONNECT.MAX_ATTEMPTS; this.reconnectDelay = this.INITIAL_RECONNECT_DELAY; + logger.ws("WebSocketManager constructed", { + url: this.url, + maxReconnectAttempts: this.MAX_RECONNECT_ATTEMPTS, + initialDelay: this.INITIAL_RECONNECT_DELAY, + maxDelay: this.MAX_RECONNECT_DELAY + }); + // Set up network status monitoring this.setupNetworkMonitoring(); } @@ -67,9 +75,13 @@ export class WebSocketManager { * Set up listeners for online/offline events */ private setupNetworkMonitoring(): void { - if (globalThis.window === undefined) return; + if (globalThis.window === undefined) { + logger.ws("Network monitoring skipped (not in browser)"); + return; + } this.isOnline = navigator.onLine; + logger.ws(`Network monitoring initialized (online: ${this.isOnline})`); globalThis.window.addEventListener("online", this.handleOnline.bind(this)); globalThis.window.addEventListener( @@ -83,7 +95,7 @@ export class WebSocketManager { * Useful for user-initiated reconnect button */ reconnect(): void { - console.info("Manual reconnect triggered"); + logger.ws("Manual reconnect triggered"); // Reset reconnect state this.reconnectAttempts = 0; @@ -103,18 +115,18 @@ export class WebSocketManager { } private handleOnline(): void { - console.info("Network connection restored"); + logger.ws("Network connection restored"); this.isOnline = true; // Automatically attempt to reconnect when network comes back if (!this.isConnected() && this.shouldReconnect) { - console.info("Auto-reconnecting after network restoration"); + logger.ws("Auto-reconnecting after network restoration"); this.reconnect(); } } private handleOffline(): void { - console.info("Network connection lost"); + logger.ws("Network connection lost"); this.isOnline = false; // Clear any pending reconnect timers when offline @@ -126,18 +138,21 @@ export class WebSocketManager { */ connect(): void { if (this.socket?.readyState === WebSocket.OPEN) { - console.warn("WebSocket already connected"); + logger.ws("Connection attempt skipped - already connected"); return; } this.shouldReconnect = true; this.setState(WEBSOCKET_STATES.CONNECTING); + logger.ws(`Connecting to WebSocket: ${this.url}`); + try { this.socket = new WebSocket(this.url); this.attachEventListeners(); + logger.ws("WebSocket instance created, waiting for connection..."); } catch (error) { - console.error("Failed to create WebSocket connection:", error); + logger.error("Failed to create WebSocket connection", error); this.setState(WEBSOCKET_STATES.ERROR); this.scheduleReconnect(); } @@ -147,6 +162,7 @@ export class WebSocketManager { * Disconnect from the WebSocket server */ disconnect(): void { + logger.ws("Disconnecting WebSocket"); this.shouldReconnect = false; this.clearReconnectTimer(); @@ -165,14 +181,19 @@ export class WebSocketManager { send(data: TRequest): void { if (this.socket?.readyState === WebSocket.OPEN) { try { + logger.ws("Sending message", data); this.socket.send(JSON.stringify(data)); } catch (error) { - console.error("Failed to send message:", error); + logger.error("Failed to send message", error); this.messageQueue.push(data); + logger.ws(`Message queued (queue size: ${this.messageQueue.length})`); } } else { - console.warn("WebSocket not connected, queuing message"); + logger.ws( + `WebSocket not connected (state: ${this.socket?.readyState}), queuing message` + ); this.messageQueue.push(data); + logger.ws(`Message queued (queue size: ${this.messageQueue.length})`); } } @@ -210,7 +231,7 @@ export class WebSocketManager { } private handleOpen(): void { - console.info("WebSocket connection opened"); + logger.ws("WebSocket connection opened successfully"); this.setState(WEBSOCKET_STATES.CONNECTED); this.reconnectAttempts = 0; this.reconnectDelay = this.INITIAL_RECONNECT_DELAY; @@ -222,19 +243,24 @@ export class WebSocketManager { private handleMessage(event: MessageEvent): void { try { const data = JSON.parse(event.data); + logger.ws("Received message", data); if (this.validateResponse(data)) { this.onMessage(data); } else { - console.error("Received invalid message format:", data); + logger.error("Received invalid message format", data); } } catch (error) { - console.error("Failed to parse WebSocket message:", error); + logger.error("Failed to parse WebSocket message", error); } } private handleClose(event: CloseEvent): void { - console.info("WebSocket connection closed:", event.code, event.reason); + logger.ws("WebSocket connection closed", { + code: event.code, + reason: event.reason || "(no reason)", + wasClean: event.wasClean + }); // Don't reconnect if it was a clean close initiated by client if (event.code === websocketCloseCodes.NORMAL && !this.shouldReconnect) { @@ -244,7 +270,7 @@ export class WebSocketManager { // Handle authentication errors (code 1008) if (event.code === websocketCloseCodes.POLICY_VIOLATION) { - console.error("WebSocket authentication failed:", event.reason); + logger.error("WebSocket authentication failed", event.reason); this.setState(WEBSOCKET_STATES.ERROR); // Don't attempt to reconnect on auth errors - user needs to re-login this.shouldReconnect = false; @@ -261,7 +287,7 @@ export class WebSocketManager { (event.reason.includes("Game not found") || event.reason.includes("Invalid game ID")) ) { - console.error("WebSocket error:", event.reason); + logger.error("WebSocket error", event.reason); this.setState(WEBSOCKET_STATES.ERROR); this.shouldReconnect = false; this.messageQueue = []; @@ -276,20 +302,22 @@ export class WebSocketManager { } private handleError(event: Event): void { - console.error("WebSocket error:", event); + logger.error("WebSocket error event", event); this.setState(WEBSOCKET_STATES.ERROR); } private scheduleReconnect(): void { // Don't schedule reconnect if offline if (!this.isOnline) { - console.info("Skipping reconnect - device is offline"); + logger.ws("Skipping reconnect - device is offline"); this.setState(WEBSOCKET_STATES.DISCONNECTED); return; } if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) { - console.error("Max reconnect attempts reached"); + logger.error( + `Max reconnect attempts reached (${this.MAX_RECONNECT_ATTEMPTS})` + ); this.setState(WEBSOCKET_STATES.ERROR); this.shouldReconnect = false; return; @@ -298,8 +326,8 @@ export class WebSocketManager { this.setState(WEBSOCKET_STATES.RECONNECTING); this.reconnectAttempts++; - console.info( - `Scheduling reconnect attempt ${this.reconnectAttempts} in ${this.reconnectDelay}ms` + logger.ws( + `Scheduling reconnect attempt ${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS} in ${this.reconnectDelay}ms` ); this.clearReconnectTimer(); @@ -317,6 +345,7 @@ export class WebSocketManager { private clearReconnectTimer(): void { if (this.reconnectTimer) { + logger.ws("Clearing reconnect timer"); clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } @@ -325,7 +354,7 @@ export class WebSocketManager { private flushMessageQueue(): void { if (this.messageQueue.length === 0) return; - console.info(`Flushing ${this.messageQueue.length} queued messages`); + logger.ws(`Flushing ${this.messageQueue.length} queued messages`); while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); @@ -337,6 +366,7 @@ export class WebSocketManager { private setState(newState: WebSocketState): void { if (this.state !== newState) { + logger.ws(`State change: ${this.state} -> ${newState}`); this.state = newState; this.onStateChange?.(newState); } @@ -346,6 +376,7 @@ export class WebSocketManager { * Cleanup resources */ destroy(): void { + logger.ws("Destroying WebSocketManager"); this.disconnect(); this.messageQueue = []; diff --git a/libs/frontend/src/routes/(authenticated)/+layout.server.ts b/libs/frontend/src/routes/(authenticated)/+layout.server.ts index 71dfea69..bdee5a0a 100644 --- a/libs/frontend/src/routes/(authenticated)/+layout.server.ts +++ b/libs/frontend/src/routes/(authenticated)/+layout.server.ts @@ -1,8 +1,8 @@ +import { searchParamKeys } from "@/config/search-params"; import { getAuthenticatedUserInfo } from "@/features/authentication/utils/get-authenticated-user-info.js"; import { redirect } from "@sveltejs/kit"; import { frontendUrls } from "types"; import type { LayoutServerLoadEvent } from "./$types"; -import { searchParamKeys } from "@/config/search-params"; export async function load({ cookies, fetch, url }: LayoutServerLoadEvent) { const { pathname } = url; diff --git a/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts b/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts index 412010f8..54d02639 100644 --- a/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts +++ b/libs/frontend/src/routes/(authenticated)/logout/+page.server.ts @@ -1,48 +1,30 @@ -import { - backendUrls, - cookieKeys, - environment, - frontendUrls, - getCookieOptions, - httpRequestMethod -} from "types"; -import type { Actions } from "./$types"; import { env } from "$env/dynamic/private"; +import { ApiError } from "$lib/api/errors"; +import { codincodApiWebAuthControllerLogout } from "$lib/api/generated"; import { redirect } from "@sveltejs/kit"; +import { cookieKeys, environment, frontendUrls, getCookieOptions } from "types"; +import type { Actions } from "./$types"; export const actions = { default: async ({ cookies, fetch }) => { - const token = cookies.get(cookieKeys.TOKEN); - try { - const backendUrl = `${env.BACKEND_HOST}${backendUrls.LOGOUT}`; - - // Call the backend logout endpoint to clear the httpOnly cookie - const response = await fetch(backendUrl, { - method: httpRequestMethod.POST, - headers: { - Cookie: `${cookieKeys.TOKEN}=${token ?? ""}` - } - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Backend logout failed:", errorText); + await codincodApiWebAuthControllerLogout({ credentials: "include" }); + } catch (error) { + if (error instanceof ApiError) { + console.error("Backend logout failed:", error.data); + } else { + console.error("Error calling logout endpoint:", error); } + } - // Also clear it on the frontend side with the same options - const isProduction = env.NODE_ENV === environment.PRODUCTION; - const cookieOptions = getCookieOptions({ - isProduction, - ...(env.FRONTEND_HOST && { frontendHost: env.FRONTEND_HOST }) - }); + const isProduction = env.NODE_ENV === environment.PRODUCTION; + const cookieOptions = getCookieOptions({ + isProduction, + ...(env.FRONTEND_HOST && { frontendHost: env.FRONTEND_HOST }) + }); - cookies.delete(cookieKeys.TOKEN, cookieOptions); - } catch (error) { - console.error("Error calling logout endpoint:", error); - } + cookies.delete(cookieKeys.TOKEN, cookieOptions); - // Redirect to home page throw redirect(303, frontendUrls.ROOT); } } satisfies Actions; diff --git a/libs/frontend/src/routes/(authenticated)/logout/+page.svelte b/libs/frontend/src/routes/(authenticated)/logout/+page.svelte index bd1e995f..9b9c92a7 100644 --- a/libs/frontend/src/routes/(authenticated)/logout/+page.svelte +++ b/libs/frontend/src/routes/(authenticated)/logout/+page.svelte @@ -1,7 +1,7 @@ + + + Forgot Password | CodinCod + + +
+ + + Back to login + +
+
+ +

Reset your password

+

+ Enter your email and we'll send you a password reset link +

+
+ + {#if form?.success} + + + Check your email + + If an account exists with this email, a password reset link has been + sent. + + + {:else} +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > +
+ {#if form?.error} + + Error + {form.error} + + {/if} + +
+ + + {#if form?.errors?.email} +

{form.errors.email}

+ {/if} +
+ + +
+
+ +

+ Remember your password? + + Sign in + +

+ {/if} +
+
diff --git a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts index 39fb7295..c849ded7 100644 --- a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts +++ b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.server.ts @@ -1,63 +1,83 @@ +import { codincodApiWebAuthControllerLogin } from "$lib/api/generated"; +import { logger } from "$lib/utils/debug-logger"; +import { searchParamKeys } from "@/config/search-params"; +import { isSvelteKitRedirect } from "@/features/authentication/utils/is-sveltekit-redirect"; +import { fail, redirect } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; import { zod4 } from "sveltekit-superforms/adapters"; -import type { RequestEvent } from "./$types"; -import { fail, redirect } from "@sveltejs/kit"; import { - backendUrls, ERROR_MESSAGES, frontendUrls, - httpRequestMethod, httpResponseCodes, loginSchema } from "types"; -import { setCookie } from "@/features/authentication/utils/set-cookie"; -import { searchParamKeys } from "@/config/search-params"; -import { buildBackendUrl } from "@/config/backend"; -import type { LoginRequest } from "types/dist/core/api/schema/auth/login.schema"; +import type { RequestEvent } from "./$types"; export async function load() { + logger.page("Login page load"); const form = await superValidate(zod4(loginSchema)); return { form }; } export const actions = { - default: async ({ cookies, request, url }: RequestEvent) => { + default: async ({ cookies, request, url, fetch }: RequestEvent) => { + console.log("[SERVER] 🔑 Login action started"); + logger.auth("🔑 Login action started"); + const form = await superValidate(request, zod4(loginSchema)); if (!form.valid) { + console.log("[SERVER] ❌ Login form validation failed", form.errors); + logger.auth("❌ Login form validation failed", form.errors); return fail(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST, { form, message: ERROR_MESSAGES.FORM.VALIDATION_ERRORS }); } - const payload: LoginRequest = { + console.log("[SERVER] Login attempt for identifier:", form.data.identifier); + logger.auth("Login attempt", { identifier: form.data.identifier, - password: form.data.password - }; - - const result = await fetch(buildBackendUrl(backendUrls.LOGIN), { - method: httpRequestMethod.POST, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + hasPassword: !!form.data.password }); - const data = await result.json(); - if (!result.ok) { - const message: string = data.message; + try { + console.log("[SERVER] Calling login endpoint using generated API"); + logger.auth("Calling login endpoint using generated API"); - return fail(httpResponseCodes.CLIENT_ERROR.BAD_REQUEST, { - form, - message - }); - } + await codincodApiWebAuthControllerLogin( + { + identifier: form.data.identifier, + password: form.data.password + }, + { credentials: "include", fetch } as RequestInit + ); + + console.log("[SERVER] ✅ Login successful"); + logger.auth("✅ Login successful"); - setCookie(result, cookies); + const redirectUrl = url.searchParams.get(searchParamKeys.REDIRECT_URL); + const redirectTo = redirectUrl ?? frontendUrls.ROOT; - const redirectUrl = url.searchParams.get(searchParamKeys.REDIRECT_URL); - const redirectTo = redirectUrl ?? frontendUrls.ROOT; + console.log("[SERVER] ✅ Login successful, redirecting to:", redirectTo); + logger.auth("✅ Login successful, redirecting to", redirectTo); - throw redirect(httpResponseCodes.REDIRECTION.FOUND, redirectTo); + throw redirect(httpResponseCodes.REDIRECTION.FOUND, redirectTo); + } catch (error) { + // Re-throw SvelteKit redirect errors (successful login) + if (isSvelteKitRedirect(error)) { + console.log("[SERVER] Redirecting after successful login"); + logger.auth("Redirecting after successful login"); + throw error; + } + + console.error("[SERVER] Login error:", error); + logger.error("Login error", error); + return fail(httpResponseCodes.SERVER_ERROR.INTERNAL_SERVER_ERROR, { + form, + message: "An unexpected error occurred. Please try again." + }); + } } }; diff --git a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte index f9d2c63a..46e43c47 100644 --- a/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte +++ b/libs/frontend/src/routes/(unauthenticated-only)/login/+page.svelte @@ -1,5 +1,5 @@ + + + Reset Password | CodinCod + + +
+
+
+ {#if form?.success} + +

+ Password reset successful +

+

+ Your password has been updated +

+ {:else} + +

Set new password

+

+ Choose a strong password for your account +

+ {/if} +
+ + {#if form?.success} +
+ + + Success + + You can now log in with your new password. + + + + +
+ {:else if !token} + + Invalid link + + This password reset link is invalid or has expired. + Request a new one + + + {:else} +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + + +
+ {#if form?.error} + + Error + {form.error} + + {/if} + +
+ + + {#if form?.errors?.password} +

{form.errors.password}

+ {/if} +
+ +
+ + + {#if password && confirmPassword && password !== confirmPassword} +

Passwords do not match

+ {/if} +
+ + +
+
+ +

+ Remember your password? + + Sign in + +

+ {/if} +
+
diff --git a/libs/frontend/src/routes/+layout.server.ts b/libs/frontend/src/routes/+layout.server.ts index a9883d22..eff4333e 100644 --- a/libs/frontend/src/routes/+layout.server.ts +++ b/libs/frontend/src/routes/+layout.server.ts @@ -1,7 +1,26 @@ +import { logger } from "$lib/utils/debug-logger"; import { getAuthenticatedUserInfo } from "@/features/authentication/utils/get-authenticated-user-info.js"; import type { ServerLoadEvent } from "@sveltejs/kit"; export async function load({ cookies, fetch }: ServerLoadEvent) { + // Server-side logs go to terminal, not browser console + console.log("[SERVER] +layout.server.ts load called"); + logger.page("+layout.server.ts load called"); + const currentUser = await getAuthenticatedUserInfo(cookies, fetch); + + console.log("[SERVER] +layout.server.ts user info:", { + isAuthenticated: currentUser.isAuthenticated, + userId: currentUser.userId, + username: currentUser.username, + role: currentUser.role + }); + logger.page("+layout.server.ts returning user data", { + isAuthenticated: currentUser.isAuthenticated, + userId: currentUser.userId, + username: currentUser.username, + role: currentUser.role + }); + return currentUser; } diff --git a/libs/frontend/src/routes/+layout.svelte b/libs/frontend/src/routes/+layout.svelte index cf699a4e..8f280324 100644 --- a/libs/frontend/src/routes/+layout.svelte +++ b/libs/frontend/src/routes/+layout.svelte @@ -1,30 +1,53 @@
- {Math.round(entry.bestScore).toLocaleString()} + {Math.round(entry.bestScore ?? 0).toLocaleString()} - {Math.round(entry.averageScore).toLocaleString()} + {Math.round(entry.averageScore ?? 0).toLocaleString()} {/each} @@ -255,8 +259,8 @@

Showing {(currentPage - 1) * pageSize + 1} to {Math.min( currentPage * pageSize, - leaderboardData.totalEntries - )} of {leaderboardData.totalEntries} players + leaderboardData.totalEntries ?? 0 + )} of {leaderboardData.totalEntries ?? 0} players