diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34b2705 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Backend Environment Variables +# Copy this file to apps/backend/.env and update with your values + +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/orders_db?schema=public" +PORT=3000 +NODE_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3a3ffa --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js +pnpm-lock.yaml + +# Testing +coverage/ +*.log + +# Production +dist/ +build/ + +# Environment +.env +.env.local +.env.*.local +.env.production + +# Keep .env.example files +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +errors.txt + +# Cache +.cache/ +.parcel-cache/ +.turbo/ +.next/ +.nuxt/ + +# Build artifacts +dist/ +out/ +tmp/ +temp/ + +# TypeScript +*.tsbuildinfo + +# Prisma +prisma/migrations/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env.test + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test +ROADMAP.md + +# Copilot +.github/copilot-instructions.md +postman/VALIDATION-REPORT.md +TEST-AUTO-SEED.md diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..81c4383 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules +pnpm-lock.yaml + +# Build outputs +dist +build +.next +out + +# Environment files +.env* +!.env.example + +# Database +prisma/migrations + +# Logs +*.log + +# Cache +.cache +.turbo +.vite + +# Generated files +*.tsbuildinfo diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5244c9e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/README.md b/README.md index 59ffa56..eadb2f2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,541 @@ -# RedditTest -TypeScript Ecosystem +# πŸš€ Full-Stack Order Management System + +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue?logo=typescript)](https://www.typescriptlang.org/) +[![React](https://img.shields.io/badge/React-19-61dafb?logo=react)](https://react.dev/) +[![Node.js](https://img.shields.io/badge/Node.js-18+-green?logo=node.js)](https://nodejs.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-14+-blue?logo=postgresql)](https://www.postgresql.org/) +[![Prisma](https://img.shields.io/badge/Prisma-6.3-2D3748?logo=prisma)](https://www.prisma.io/) + +A modern, production-ready order management application built with **TypeScript**, **React 19**, **Express**, **Prisma**, and **PostgreSQL**. Features complete CRUD operations, pagination, filtering, end-to-end type safety, and professional UX patterns. + +--- + +## πŸ“‹ Table of Contents + +- [Features](#-features) +- [Tech Stack](#-tech-stack) +- [Quick Start](#-quick-start) +- [Development](#-development) +- [Testing](#-testing) +- [API Documentation](#-api-documentation) +- [Architecture](#-architecture) +- [Project Structure](#-project-structure) + +--- + +## ✨ Features + +### Core Functionality +- βœ… **Full CRUD Operations** - Create, Read, Update, Delete orders +- βœ… **Server-side Pagination** - Efficient data loading with configurable page size +- βœ… **Status Filtering** - Filter orders by PENDING, COMPLETED, or CANCELLED status +- βœ… **Real-time Validation** - Zod schemas validate data on both client and server +- βœ… **End-to-end Type Safety** - Shared types between frontend and backend + +### User Experience +- βœ… **Toast Notifications** - Professional feedback for all actions +- βœ… **Status Badges** - Color-coded badges (🟑 Pending, 🟒 Completed, πŸ”΄ Cancelled) +- βœ… **Delete Confirmation** - Modal dialog prevents accidental deletions +- βœ… **Loading States** - Clear feedback during async operations +- βœ… **Error Handling** - User-friendly error messages +- βœ… **Responsive Design** - Mobile-friendly UI with TailwindCSS + +### Technical Excellence +- βœ… **Monorepo Architecture** - pnpm workspaces with shared types +- βœ… **React Query** - Automatic caching, optimistic updates, cache invalidation +- βœ… **Type-First Development** - Zod schemas generate TypeScript types +- βœ… **Testing** - 15 tests total (10 backend + 5 frontend) +- βœ… **Code Quality** - ESLint, Prettier, and strict TypeScript + +--- + +## πŸ› οΈ Tech Stack + +### Backend +- **Runtime**: Node.js 18+ with TypeScript +- **Framework**: Express.js +- **Database**: PostgreSQL 14+ with Prisma ORM +- **Validation**: Zod (runtime validation + types) +- **Testing**: Jest + Supertest (10 tests) + +### Frontend +- **Framework**: React 19 with TypeScript +- **Build Tool**: Vite +- **Styling**: TailwindCSS +- **State Management**: React Query (server state) +- **Forms**: React Hook Form + Zod resolver +- **Notifications**: React Hot Toast +- **Testing**: Vitest + React Testing Library (5 tests) + +### Shared +- **Monorepo**: pnpm workspaces +- **Types**: Shared Zod schemas in `@shared/types` +- **Code Quality**: ESLint + Prettier + +--- + +## πŸš€ Quick Start + +### Prerequisites + +- **Node.js** >= 18.0.0 +- **pnpm** >= 8.0.0 +- **PostgreSQL** >= 14 (running locally) + +### 1. Clone & Install + +```bash +git clone +cd RedditTest +pnpm install +``` + +### 2. Setup Environment Variables + +**Backend** (`apps/backend/.env`): + +```bash +# Copy example file +cp apps/backend/.env.example apps/backend/.env +``` + +Edit with your PostgreSQL credentials: + +```env +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/orders_db?schema=public" +PORT=3000 +NODE_ENV=development +``` + +**Frontend** (`apps/frontend/.env`): + +```bash +# Copy example file +cp apps/frontend/.env.example apps/frontend/.env +``` + +Default values should work: + +```env +VITE_API_URL=http://localhost:3000/api +``` + +### 3. Setup Database + +```bash +# Generate Prisma client +pnpm --filter backend prisma:generate + +# Run migrations (creates tables) +pnpm --filter backend prisma:migrate dev + +# (Optional) Seed database +pnpm --filter backend prisma:seed +``` + +### 4. Start Development Servers + +**Terminal 1 - Backend:** + +```bash +pnpm dev:backend +``` + +πŸš€ Backend: http://localhost:3000 + +**Terminal 2 - Frontend:** + +```bash +pnpm dev:frontend +``` + +🎨 Frontend: http://localhost:5173 + +> **Note:** The backend automatically seeds the database with 10 sample orders on first run if the database is empty. + +--- + +## πŸ’» Development + +### Quick Command Reference + +```bash +# Development +pnpm dev:backend # Start backend (auto-seeds if empty) +pnpm dev:frontend # Start frontend + +# Testing +pnpm --filter backend test # Run backend tests (10 tests) +pnpm --filter frontend test --run # Run frontend tests (5 tests) + +# Database +pnpm --filter backend prisma:studio # Open database GUI (port 5555) +pnpm --filter backend prisma:generate # Regenerate Prisma client +pnpm --filter backend prisma:migrate dev # Create new migration +pnpm --filter backend prisma:seed # Manual seed +pnpm --filter backend prisma:migrate reset # ⚠️ Reset database (deletes all data) + +# Build +pnpm --filter backend build # Build backend +pnpm --filter frontend build # Build frontend +``` + +### Database Management + +**Prisma Studio** - Visual database editor: + +```bash +pnpm --filter backend prisma:studio +``` + +Opens at http://localhost:5555 + +**Seeding Behavior:** +- Seed script checks if data exists before inserting +- Only seeds if database is empty +- To reseed: `pnpm --filter backend prisma:migrate reset` + +--- + +## πŸ§ͺ Testing + +### Run Tests + +```bash +# Backend tests (Jest + Supertest) +pnpm --filter backend test + +# Frontend tests (Vitest + React Testing Library) +pnpm --filter frontend test --run + +# All tests +pnpm --filter backend test && pnpm --filter frontend test --run +``` + +### Test Coverage + +**Backend (10 tests):** +- βœ… Pagination with metadata +- βœ… Order creation with validation +- βœ… Order retrieval by ID +- βœ… Status filtering +- βœ… Error handling (404, 400, validation) + +**Frontend (5 tests):** +- βœ… Component rendering +- βœ… Loading states +- βœ… React Query hook initialization + +### API Testing with Postman + +1. Import `postman/Order_Management_API.postman_collection.json` +2. Import `postman/Order_Management_Dev.postman_environment.json` +3. Select "Order Management - Development" environment +4. Run collection to test all endpoints + +--- + +## πŸ“– API Documentation + +### Endpoints + +| Method | Endpoint | Description | Query Parameters | +| -------- | ----------------- | ------------------------- | ----------------------------- | +| `GET` | `/health` | Health check | - | +| `GET` | `/api/orders` | Get paginated orders | `page`, `page_size`, `status` | +| `GET` | `/api/orders/:id` | Get order by ID | - | +| `POST` | `/api/orders` | Create new order | - | +| `PUT` | `/api/orders/:id` | Update order | - | +| `DELETE` | `/api/orders/:id` | Delete order | - | + +### Query Parameters + +- **page** - Page number (default: 1) +- **page_size** - Items per page (default: 10, max: 100) +- **status** - Filter by status: `PENDING`, `COMPLETED`, or `CANCELLED` + +### Example Requests + +#### Get All Orders (Paginated) +```bash +GET http://localhost:3000/api/orders?page=1&page_size=10 +``` + +#### Filter by Status +```bash +GET http://localhost:3000/api/orders?status=PENDING +``` + +#### Create Order +```bash +POST http://localhost:3000/api/orders +Content-Type: application/json + +{ + "customer_name": "John Doe", + "item": "MacBook Pro", + "quantity": 2, + "status": "PENDING" +} +``` + +#### Update Order +```bash +PUT http://localhost:3000/api/orders/{id} +Content-Type: application/json + +{ + "status": "COMPLETED" +} +``` + +#### Delete Order +```bash +DELETE http://localhost:3000/api/orders/{id} +``` + +### Response Format + +**Success Response:** +```json +{ + "success": true, + "data": { ... } +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": { + "message": "Error message", + "code": "ERROR_CODE", + "details": { ... } + } +} +``` + +--- + +## πŸ—οΈ Architecture + +### Monorepo Structure + +``` +RedditTest/ +β”œβ”€β”€ apps/ +β”‚ β”œβ”€β”€ backend/ # Express.js API + Prisma ORM +β”‚ β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”‚ β”œβ”€β”€ controllers/ # HTTP request handlers +β”‚ β”‚ β”‚ β”œβ”€β”€ middleware/ # Error handling, validation +β”‚ β”‚ β”‚ β”œβ”€β”€ routes/ # API route definitions +β”‚ β”‚ β”‚ └── __tests__/ # Integration tests +β”‚ β”‚ └── prisma/ +β”‚ β”‚ β”œβ”€β”€ schema.prisma # Database schema +β”‚ β”‚ └── seed.ts # Database seeding +β”‚ └── frontend/ # React 19 + Vite SPA +β”‚ └── src/ +β”‚ β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ pages/ # Route pages +β”‚ β”œβ”€β”€ hooks/ # React Query hooks +β”‚ └── services/ # API client +β”œβ”€β”€ packages/ +β”‚ └── shared-types/ # Shared Zod schemas & TypeScript types +└── postman/ # API testing collection +``` + +### Type-First Development Flow + +1. **Define Zod schema** in `packages/shared-types/src/index.ts`: + ```typescript + export const CreateOrderSchema = z.object({ + customer_name: z.string().min(1).max(255), + quantity: z.number().int().positive(), + status: OrderStatusSchema + }); + ``` + +2. **Infer TypeScript type**: + ```typescript + export type CreateOrderDTO = z.infer; + ``` + +3. **Backend validates** with same schema: + ```typescript + const validated = CreateOrderSchema.parse(req.body); + ``` + +4. **Frontend uses** without duplication: + ```typescript + import { CreateOrderDTO } from '@shared/types'; + const orderData: CreateOrderDTO = { ... }; + ``` + +**Result:** Change validation once, get runtime safety + TypeScript types everywhere. No drift between frontend and backend. + +### Key Architectural Decisions + +#### Why Monorepo? +- Shared types eliminate duplication +- Single source of truth for validation +- Changes instantly available across apps + +#### Why Zod? +- Runtime validation catches errors at API boundaries +- TypeScript types auto-generated from schemas +- Single schema for validation + types + +#### Why React Query? +- Automatic caching reduces API calls +- Optimistic updates for instant UI feedback +- Cache invalidation keeps data fresh +- Replaces Redux/Zustand for server state + +#### Why Prisma? +- Type-safe database access prevents SQL errors +- Automatic migrations track schema changes +- Generated client provides autocomplete + +#### Why No Repository Layer? +- Simple domain (single entity) doesn't need abstraction +- Prisma calls are clear and type-safe +- Easy to refactor when complexity grows + +--- + +## πŸ“ Project Structure + +### Backend Organization + +``` +apps/backend/src/ +β”œβ”€β”€ index.ts # Express app setup + middleware +β”œβ”€β”€ controllers/ +β”‚ └── orders.controller.ts # Order CRUD logic +β”œβ”€β”€ middleware/ +β”‚ β”œβ”€β”€ errorHandler.ts # Centralized error handling +β”‚ └── validateUUID.ts # UUID validation middleware +β”œβ”€β”€ routes/ +β”‚ └── orders.routes.ts # Route definitions +└── __tests__/ + β”œβ”€β”€ setup.ts # Test configuration + └── orders.test.ts # Integration tests +``` + +### Frontend Organization + +``` +apps/frontend/src/ +β”œβ”€β”€ main.tsx # App entry point +β”œβ”€β”€ App.tsx # Router setup +β”œβ”€β”€ components/ +β”‚ β”œβ”€β”€ ConfirmDialog.tsx # Delete confirmation modal +β”‚ β”œβ”€β”€ Layout.tsx # Page layout wrapper +β”‚ └── StatusBadge.tsx # Status display component +β”œβ”€β”€ pages/ +β”‚ β”œβ”€β”€ OrdersListPage.tsx # List view with pagination +β”‚ β”œβ”€β”€ OrderDetailsPage.tsx # Single order view +β”‚ β”œβ”€β”€ CreateOrderPage.tsx # Create form +β”‚ └── EditOrderPage.tsx # Edit form +β”œβ”€β”€ hooks/ +β”‚ └── useOrders.ts # React Query hooks +β”œβ”€β”€ services/ +β”‚ └── orders.service.ts # API client +└── lib/ + └── api.ts # Axios configuration +``` + +### Shared Types + +```typescript +// packages/shared-types/src/index.ts + +// Schemas (validation + types) +export const OrderStatusSchema = z.enum(['PENDING', 'COMPLETED', 'CANCELLED']); +export const CreateOrderSchema = z.object({ ... }); +export const UpdateOrderSchema = z.object({ ... }); + +// Types (inferred from schemas) +export type OrderStatus = z.infer; +export type CreateOrderDTO = z.infer; +export type Order = { ... }; +export type ApiResponse = { ... }; +``` + +--- + +## 🎯 Requirements Checklist + +### βœ… Core Requirements (100%) + +**Backend:** +- βœ… All CRUD endpoints (POST, GET, PUT, DELETE) +- βœ… Order structure with UUID, customer_name, item, quantity, status, created_at +- βœ… Pagination support with page and page_size +- βœ… Node.js + TypeScript + Express +- βœ… PostgreSQL database with Prisma ORM + +**Frontend:** +- βœ… Order list view with pagination +- βœ… Order details view +- βœ… Create/Edit order forms +- βœ… Delete functionality +- βœ… Loading and error states +- βœ… TypeScript + React 19 + +### ⭐ Bonus Features (100%) + +- βœ… Status filtering (backend + frontend) +- βœ… Comprehensive testing (15 tests) +- βœ… Shared types monorepo setup +- βœ… Comprehensive documentation +- βœ… ESLint + Prettier configuration +- βœ… Professional error handling +- βœ… Toast notifications +- βœ… Postman collection + +--- + +## 🚨 Important Notes + +### Prisma Client Generation +After modifying `prisma/schema.prisma`, always regenerate the client: + +```bash +pnpm --filter backend prisma:generate +``` + +**Symptom of missing generation:** `Property 'order' does not exist on type 'PrismaClient'` + +### Workspace Linking +Shared types use `workspace:*` protocol. Changes are immediately available without reinstalling. + +If you see stale types: +```bash +pnpm install # Rebuild workspace links +``` + +### Auto-Seeding +The backend checks if the database is empty and automatically seeds on first run. Manual seeding only needed after database reset. + +### Vite Configuration +This project uses `rolldown-vite@7.1.14` (specified in root `package.json` overrides). If you encounter Vite type errors, ensure `apps/frontend/src/vite-env.d.ts` exists. + +--- + +## πŸ“š Additional Resources + +- **Prisma Studio**: Visual database editor at http://localhost:5555 +- **Postman Collection**: Pre-configured API tests in `/postman` directory +- **Type Definitions**: All schemas in `packages/shared-types/src/index.ts` + +--- + +## 🀝 Contributing + +This is a technical challenge project. For questions or issues, please open a GitHub issue. + +--- + +**Built with** ❀️ using TypeScript, React 19, Node.js, Express, PostgreSQL, and Prisma. diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..32c8265 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,8 @@ +# Database Connection +# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public +# Example for local development: +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/orders_db?schema=public" + +# Server Configuration +PORT=3000 +NODE_ENV=development diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..73d4598 --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,34 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules +pnpm-lock.yaml + +# Build output +dist + +# Environment +.env +.env.local +.env.*.local +.env.production +.env.test + +# TypeScript +*.tsbuildinfo + +# Prisma +prisma/migrations/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock diff --git a/apps/backend/jest.config.js b/apps/backend/jest.config.js new file mode 100644 index 0000000..d3defca --- /dev/null +++ b/apps/backend/jest.config.js @@ -0,0 +1,23 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + moduleNameMapper: { + '^@shared/types$': '/../../packages/shared-types/src/index.ts', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/__tests__/**', + ], + coverageThreshold: { + global: { + branches: 50, + functions: 50, + lines: 50, + statements: 50, + }, + }, +}; diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..5375860 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,40 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "Order Management API with Express and Prisma", + "main": "dist/index.js", + "scripts": { + "dev": "tsx scripts/dev-with-seed.ts", + "dev:simple": "tsx watch src/index.ts", + "dev:seed": "tsx prisma/seed.ts && tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "tsx prisma/seed.ts", + "prisma:studio": "prisma studio", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch" + }, + "dependencies": { + "@prisma/client": "^6.3.0", + "@shared/types": "workspace:*", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.5", + "@types/supertest": "^6.0.2", + "jest": "^29.7.0", + "prisma": "^6.3.0", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma new file mode 100644 index 0000000..253ca53 --- /dev/null +++ b/apps/backend/prisma/schema.prisma @@ -0,0 +1,30 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum OrderStatus { + PENDING + COMPLETED + CANCELLED +} + +model Order { + id String @id @default(uuid()) + customer_name String @db.VarChar(255) + item String @db.VarChar(255) + quantity Int + status OrderStatus @default(PENDING) + created_at DateTime @default(now()) + + @@index([status]) + @@index([created_at]) + @@map("orders") +} diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts new file mode 100644 index 0000000..6cbfe11 --- /dev/null +++ b/apps/backend/prisma/seed.ts @@ -0,0 +1,100 @@ +import { PrismaClient } from '@prisma/client'; +import { OrderStatus } from '@shared/types'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('🌱 Seeding database...'); + + // Check if database already has data + const existingOrders = await prisma.order.count(); + + if (existingOrders > 0) { + console.log(`πŸ“¦ Database already has ${existingOrders} orders. Skipping seed.`); + console.log('πŸ’‘ Tip: To reset database, run: pnpm prisma:migrate reset'); + return; + } + + // Create sample orders only if database is empty + const orders = [ + { + customer_name: 'John Doe', + item: 'Laptop HP ProBook', + quantity: 2, + status: OrderStatus.PENDING, + }, + { + customer_name: 'Jane Smith', + item: 'iPhone 15 Pro', + quantity: 1, + status: OrderStatus.COMPLETED, + }, + { + customer_name: 'Bob Johnson', + item: 'Samsung Galaxy S24', + quantity: 3, + status: OrderStatus.PENDING, + }, + { + customer_name: 'Alice Williams', + item: 'MacBook Air M3', + quantity: 1, + status: OrderStatus.COMPLETED, + }, + { + customer_name: 'Charlie Brown', + item: 'Dell XPS 15', + quantity: 2, + status: OrderStatus.CANCELLED, + }, + { + customer_name: 'Diana Prince', + item: 'iPad Pro 12.9', + quantity: 4, + status: OrderStatus.PENDING, + }, + { + customer_name: 'Edward Norton', + item: 'Sony WH-1000XM5', + quantity: 1, + status: OrderStatus.COMPLETED, + }, + { + customer_name: 'Fiona Green', + item: 'AirPods Pro 2', + quantity: 5, + status: OrderStatus.PENDING, + }, + { + customer_name: 'George Miller', + item: 'Samsung Monitor 32"', + quantity: 2, + status: OrderStatus.CANCELLED, + }, + { + customer_name: 'Helen Carter', + item: 'Logitech MX Master 3S', + quantity: 3, + status: OrderStatus.COMPLETED, + }, + ]; + + for (const order of orders) { + await prisma.order.create({ + data: order, + }); + } + + console.log('βœ… Database seeded successfully!'); + console.log(`πŸ“¦ Created ${orders.length} orders`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error('❌ Error seeding database:', e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/backend/scripts/dev-with-seed.ts b/apps/backend/scripts/dev-with-seed.ts new file mode 100644 index 0000000..ca92596 --- /dev/null +++ b/apps/backend/scripts/dev-with-seed.ts @@ -0,0 +1,61 @@ +import { PrismaClient } from '@prisma/client'; +import { spawn } from 'child_process'; +import path from 'path'; + +const prisma = new PrismaClient(); + +async function checkAndSeed() { + try { + // Check if database has data + const orderCount = await prisma.order.count(); + + if (orderCount === 0) { + console.log('🌱 Empty database detected. Running seed...'); + + // Run seed script + const seedProcess = spawn('tsx', ['prisma/seed.ts'], { + cwd: path.resolve(__dirname, '..'), + stdio: 'inherit', + shell: true + }); + + await new Promise((resolve, reject) => { + seedProcess.on('close', (code) => { + if (code === 0) { + resolve(true); + } else { + reject(new Error(`Seed failed with code ${code}`)); + } + }); + }); + } else { + console.log(`πŸ“¦ Database has ${orderCount} orders. Skipping seed.`); + } + } catch (error) { + console.error('❌ Error checking/seeding database:', error); + console.log('⚠️ Starting server anyway...'); + } finally { + await prisma.$disconnect(); + } +} + +async function startDevServer() { + console.log('πŸš€ Starting development server...\n'); + + // Start tsx watch + const devProcess = spawn('tsx', ['watch', 'src/index.ts'], { + cwd: path.resolve(__dirname, '..'), + stdio: 'inherit', + shell: true + }); + + devProcess.on('close', (code) => { + process.exit(code || 0); + }); +} + +// Main execution +(async () => { + await checkAndSeed(); + startDevServer(); +})(); diff --git a/apps/backend/src/__tests__/orders.test.ts b/apps/backend/src/__tests__/orders.test.ts new file mode 100644 index 0000000..218015e --- /dev/null +++ b/apps/backend/src/__tests__/orders.test.ts @@ -0,0 +1,222 @@ +import request from 'supertest'; +import express from 'express'; +import cors from 'cors'; +import ordersRoutes from '../routes/orders.routes'; +import { errorHandler } from '../middleware/errorHandler'; +import { prisma } from './setup'; +import { OrderStatus } from '@shared/types'; + +// Setup app for testing +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use('/api/orders', ordersRoutes); +app.use(errorHandler); + +describe('Orders API Tests', () => { + // Lifecycle hooks + beforeAll(async () => { + await prisma.$connect(); + }); + + afterAll(async () => { + await prisma.order.deleteMany({}); + await prisma.$disconnect(); + }); + + afterEach(async () => { + await prisma.order.deleteMany({}); + }); + + // Helper to create test order + const createTestOrder = async (data = {}) => { + return await prisma.order.create({ + data: { + customer_name: 'Test Customer', + item: 'Test Item', + quantity: 1, + status: OrderStatus.PENDING, + ...data, + }, + }); + }; + + describe('GET /api/orders - Pagination', () => { + it('should return paginated orders with correct metadata', async () => { + // Create 5 test orders + for (let i = 1; i <= 5; i++) { + await createTestOrder({ + customer_name: `Customer ${i}`, + item: `Item ${i}`, + }); + } + + const response = await request(app) + .get('/api/orders') + .query({ page: 1, page_size: 2 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.data).toHaveLength(2); + + // Verify that pagination works correctly + const pagination = response.body.data.pagination; + expect(pagination.page).toBe(1); + expect(pagination.page_size).toBe(2); + expect(pagination.total).toBeGreaterThanOrEqual(5); // At least 5 orders + expect(pagination.total_pages).toBeGreaterThanOrEqual(3); // At least 3 pages + }); + + it('should return empty page when no data exists', async () => { + const response = await request(app) + .get('/api/orders') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.data).toHaveLength(0); + expect(response.body.data.pagination.total).toBe(0); + }); + }); + + describe('POST /api/orders - Create order', () => { + it('should create a new order with valid data', async () => { + const newOrder = { + customer_name: 'John Doe', + item: 'MacBook Pro', + quantity: 2, + status: OrderStatus.PENDING, + }; + + const response = await request(app) + .post('/api/orders') + .send(newOrder) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toMatchObject({ + customer_name: newOrder.customer_name, + item: newOrder.item, + quantity: newOrder.quantity, + status: newOrder.status, + }); + expect(response.body.data.id).toBeDefined(); + expect(response.body.data.created_at).toBeDefined(); + }); + + it('should reject order with invalid data', async () => { + const invalidOrder = { + customer_name: '', // empty + item: 'Test', + quantity: -1, // negative + status: 'INVALID_STATUS', // invalid status + }; + + const response = await request(app) + .post('/api/orders') + .send(invalidOrder) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('GET /api/orders/:id - Get by ID', () => { + it('should return an existing order', async () => { + const order = await createTestOrder({ + customer_name: 'Jane Doe', + item: 'iPhone 15', + }); + + const response = await request(app) + .get(`/api/orders/${order.id}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.id).toBe(order.id); + expect(response.body.data.customer_name).toBe('Jane Doe'); + }); + + it('should return 404 if order does not exist', async () => { + const fakeId = '123e4567-e89b-12d3-a456-426614174000'; + + const response = await request(app) + .get(`/api/orders/${fakeId}`) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.error.code).toBe('ORDER_NOT_FOUND'); + }); + + it('should return 400 with invalid UUID', async () => { + const response = await request(app) + .get('/api/orders/invalid-uuid') + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('GET /api/orders?status - Filter by status', () => { + it('should filter orders by PENDING status', async () => { + // Create orders with different statuses + await createTestOrder({ status: OrderStatus.PENDING }); + await createTestOrder({ status: OrderStatus.PENDING }); + await createTestOrder({ status: OrderStatus.COMPLETED }); + await createTestOrder({ status: OrderStatus.CANCELLED }); + + const response = await request(app) + .get('/api/orders') + .query({ status: OrderStatus.PENDING }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.data).toHaveLength(2); + expect(response.body.data.pagination.total).toBe(2); + + // Verify all orders are PENDING + response.body.data.data.forEach((order: any) => { + expect(order.status).toBe(OrderStatus.PENDING); + }); + }); + + it('should filter orders by COMPLETED status', async () => { + await createTestOrder({ status: OrderStatus.PENDING }); + await createTestOrder({ status: OrderStatus.COMPLETED }); + await createTestOrder({ status: OrderStatus.COMPLETED }); + + const response = await request(app) + .get('/api/orders') + .query({ status: OrderStatus.COMPLETED }) + .expect(200); + + expect(response.body.data.data).toHaveLength(2); + response.body.data.data.forEach((order: any) => { + expect(order.status).toBe(OrderStatus.COMPLETED); + }); + }); + + it('should combine filtering with pagination', async () => { + // Create 5 PENDING orders + for (let i = 0; i < 5; i++) { + await createTestOrder({ status: OrderStatus.PENDING }); + } + // Create 3 COMPLETED orders + for (let i = 0; i < 3; i++) { + await createTestOrder({ status: OrderStatus.COMPLETED }); + } + + const response = await request(app) + .get('/api/orders') + .query({ status: OrderStatus.PENDING, page: 1, page_size: 3 }) + .expect(200); + + expect(response.body.data.data).toHaveLength(3); + expect(response.body.data.pagination).toMatchObject({ + page: 1, + page_size: 3, + total: 5, + total_pages: 2, + }); + }); + }); +}); diff --git a/apps/backend/src/__tests__/setup.ts b/apps/backend/src/__tests__/setup.ts new file mode 100644 index 0000000..b136117 --- /dev/null +++ b/apps/backend/src/__tests__/setup.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, +}); + +// Setup se ejecuta automΓ‘ticamente por Jest +// Los hooks beforeAll, afterAll, afterEach se manejan en cada test file + diff --git a/apps/backend/src/config/database.ts b/apps/backend/src/config/database.ts new file mode 100644 index 0000000..e7a23ee --- /dev/null +++ b/apps/backend/src/config/database.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +}); + +// Handle graceful shutdown +process.on('beforeExit', async () => { + await prisma.$disconnect(); +}); + +export default prisma; diff --git a/apps/backend/src/controllers/orders.controller.ts b/apps/backend/src/controllers/orders.controller.ts new file mode 100644 index 0000000..d0f0736 --- /dev/null +++ b/apps/backend/src/controllers/orders.controller.ts @@ -0,0 +1,208 @@ +import { Request, Response, NextFunction } from 'express'; +import { + CreateOrderSchema, + UpdateOrderSchema, + OrdersQuerySchema, + ApiResponse, + Order, + PaginatedResponse, +} from '@shared/types'; +import prisma from '../config/database'; +import { AppError } from '../middleware/errorHandler'; + +/** + * GET /api/orders + * Get paginated list of orders with optional status filter + */ +export const getOrders = async ( + req: Request, + res: Response>>, + next: NextFunction +) => { + try { + // Validate and parse query parameters + const query = OrdersQuerySchema.parse({ + page: req.query.page ? Number(req.query.page) : 1, + page_size: req.query.page_size ? Number(req.query.page_size) : 10, + status: req.query.status, + }); + + const { page, page_size, status } = query; + const skip = (page - 1) * page_size; + + // Build where clause + const where = status ? { status } : {}; + + // Get total count and orders + const [total, orders] = await Promise.all([ + prisma.order.count({ where }), + prisma.order.findMany({ + where, + skip, + take: page_size, + orderBy: { created_at: 'desc' }, + }), + ]); + + const total_pages = Math.ceil(total / page_size); + + res.json({ + success: true, + data: { + data: orders.map((order) => ({ + ...order, + created_at: order.created_at.toISOString(), + })), + pagination: { + page, + page_size, + total, + total_pages, + }, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/orders/:id + * Get order by ID + */ +export const getOrderById = async ( + req: Request, + res: Response>, + next: NextFunction +) => { + try { + const { id } = req.params; + + const order = await prisma.order.findUnique({ + where: { id }, + }); + + if (!order) { + throw new AppError(404, 'Order not found', 'ORDER_NOT_FOUND'); + } + + res.json({ + success: true, + data: { + ...order, + created_at: order.created_at.toISOString(), + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /api/orders + * Create a new order + */ +export const createOrder = async ( + req: Request, + res: Response>, + next: NextFunction +) => { + try { + // Validate request body + const validatedData = CreateOrderSchema.parse(req.body); + + const order = await prisma.order.create({ + data: validatedData, + }); + + res.status(201).json({ + success: true, + data: { + ...order, + created_at: + order.created_at instanceof Date ? order.created_at.toISOString() : order.created_at, + }, + message: 'Order created successfully', + }); + } catch (error) { + next(error); + } +}; + +/** + * PUT /api/orders/:id + * Update an existing order + */ +export const updateOrder = async ( + req: Request, + res: Response>, + next: NextFunction +) => { + try { + const { id } = req.params; + + // Check if order exists + const existingOrder = await prisma.order.findUnique({ + where: { id }, + }); + + if (!existingOrder) { + throw new AppError(404, 'Order not found', 'ORDER_NOT_FOUND'); + } + + // Validate request body + const validatedData = UpdateOrderSchema.parse(req.body); + + const order = await prisma.order.update({ + where: { id }, + data: validatedData, + }); + + res.json({ + success: true, + data: { + ...order, + created_at: + order.created_at instanceof Date ? order.created_at.toISOString() : order.created_at, + }, + message: 'Order updated successfully', + }); + } catch (error) { + next(error); + } +}; + +/** + * DELETE /api/orders/:id + * Delete an order + */ +export const deleteOrder = async ( + req: Request, + res: Response>, + next: NextFunction +) => { + try { + const { id } = req.params; + + // Check if order exists + const existingOrder = await prisma.order.findUnique({ + where: { id }, + }); + + if (!existingOrder) { + throw new AppError(404, 'Order not found', 'ORDER_NOT_FOUND'); + } + + await prisma.order.delete({ + where: { id }, + }); + + res.json({ + success: true, + data: { id }, + message: 'Order deleted successfully', + }); + } catch (error) { + next(error); + } +}; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000..deb48ba --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,35 @@ +import express, { Application } from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import { errorHandler } from './middleware/errorHandler'; +import ordersRouter from './routes/orders.routes'; + +// Load environment variables +dotenv.config(); + +const app: Application = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Health check endpoint +app.get('/health', (_req, res) => { + res.json({ status: 'ok', message: 'Order Management API is running' }); +}); + +// API Routes +app.use('/api/orders', ordersRouter); + +// Error handling middleware (must be last) +app.use(errorHandler); + +// Start server +app.listen(PORT, () => { + console.log(`πŸš€ Server running on http://localhost:${PORT}`); + console.log(`πŸ“š API available at http://localhost:${PORT}/api/orders`); +}); + +export default app; diff --git a/apps/backend/src/middleware/errorHandler.ts b/apps/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..3408257 --- /dev/null +++ b/apps/backend/src/middleware/errorHandler.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodError } from 'zod'; +import { ApiError } from '@shared/types'; + +export class AppError extends Error { + constructor( + public statusCode: number, + public message: string, + public code: string = 'INTERNAL_ERROR' + ) { + super(message); + this.name = 'AppError'; + Error.captureStackTrace(this, this.constructor); + } +} + +export const errorHandler = ( + err: Error | AppError | ZodError, + _req: Request, + res: Response, + _next: NextFunction +) => { + console.error('❌ Error:', err); + + // Handle Zod validation errors + if (err instanceof ZodError) { + return res.status(400).json({ + success: false, + error: { + message: 'Validation error', + code: 'VALIDATION_ERROR', + details: err.errors, + }, + }); + } + + // Handle custom AppError + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + success: false, + error: { + message: err.message, + code: err.code, + }, + }); + } + + // Handle generic errors + return res.status(500).json({ + success: false, + error: { + message: 'Internal server error', + code: 'INTERNAL_ERROR', + details: process.env.NODE_ENV === 'development' ? err.message : undefined, + }, + }); +}; diff --git a/apps/backend/src/middleware/validateUUID.ts b/apps/backend/src/middleware/validateUUID.ts new file mode 100644 index 0000000..09309ad --- /dev/null +++ b/apps/backend/src/middleware/validateUUID.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AppError } from './errorHandler'; + +/** + * UUID validation schema + */ +const UUIDSchema = z.string().uuid('Invalid UUID format'); + +/** + * Middleware to validate UUID parameters + */ +export const validateUUID = (paramName: string = 'id') => { + return (req: Request, res: Response, next: NextFunction) => { + try { + const paramValue = req.params[paramName]; + + if (!paramValue) { + throw new AppError(400, `Parameter ${paramName} is required`, 'MISSING_PARAMETER'); + } + + // Validate UUID format + UUIDSchema.parse(paramValue); + + next(); + } catch (error) { + if (error instanceof z.ZodError) { + next(new AppError(400, `Invalid ${paramName} format`, 'INVALID_UUID')); + } else { + next(error); + } + } + }; +}; + +/** + * Validate UUID string directly + */ +export const isValidUUID = (value: string): boolean => { + try { + UUIDSchema.parse(value); + return true; + } catch { + return false; + } +}; diff --git a/apps/backend/src/routes/orders.routes.ts b/apps/backend/src/routes/orders.routes.ts new file mode 100644 index 0000000..0bcadd6 --- /dev/null +++ b/apps/backend/src/routes/orders.routes.ts @@ -0,0 +1,44 @@ +import { Router, type IRouter } from 'express'; +import { + getOrders, + getOrderById, + createOrder, + updateOrder, + deleteOrder, +} from '../controllers/orders.controller'; +import { validateUUID } from '../middleware/validateUUID'; + +const router: IRouter = Router(); + +/** + * @route GET /api/orders + * @desc Get paginated list of orders + * @query page, page_size, status + */ +router.get('/', getOrders); + +/** + * @route GET /api/orders/:id + * @desc Get order by ID + */ +router.get('/:id', validateUUID('id'), getOrderById); + +/** + * @route POST /api/orders + * @desc Create a new order + */ +router.post('/', createOrder); + +/** + * @route PUT /api/orders/:id + * @desc Update order by ID + */ +router.put('/:id', validateUUID('id'), updateOrder); + +/** + * @route DELETE /api/orders/:id + * @desc Delete order by ID + */ +router.delete('/:id', validateUUID('id'), deleteOrder); + +export default router; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..bc19071 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node", "jest"], + "paths": { + "@shared/types": ["../../packages/shared-types/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 0000000..b7df372 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1,6 @@ +# Backend API URL +# Local development (default): +VITE_API_URL=http://localhost:3000/api + +# Production example: +# VITE_API_URL=https://your-api-domain.com/api diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..30d2a9d --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules +pnpm-lock.yaml + +# Build output +dist +dist-ssr +*.local + +# Environment +.env +.env.local +.env.*.local +.env.production + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# TypeScript +*.tsbuildinfo diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..9f6cb04 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,64 @@ +# 🎨 Frontend - Order Management + +React 19 + TypeScript + Vite frontend for the Order Management System. + +## πŸš€ Quick Start + +### Development + +```bash +# From project root +pnpm dev:frontend +``` + +🎨 Runs on: http://localhost:5173 + +### Build + +```bash +pnpm build:frontend +pnpm --filter frontend preview +``` + +## πŸ—οΈ Tech Stack + +- **React 19** - Latest React with new features +- **TypeScript** - Type safety throughout +- **Vite** - Fast build tool with HMR +- **TailwindCSS** - Utility-first styling +- **React Query** - Server state management +- **React Router** - Navigation & routing +- **React Hook Form** - Form handling +- **Axios** - HTTP client with types + +## πŸ“± Features + +βœ… **Order List** - Paginated table with search/filter +βœ… **Order Details** - Full order information view +βœ… **Create/Edit** - Form validation with Zod schemas +βœ… **Delete** - Confirmation dialogs +βœ… **Status Filter** - Filter by pending/completed/cancelled +βœ… **Loading States** - Skeleton loaders +βœ… **Error Handling** - User-friendly error messages +βœ… **Responsive** - Mobile-first design + +## πŸ—‚οΈ Structure + +``` +src/ +β”œβ”€β”€ components/ # Reusable UI components +β”œβ”€β”€ hooks/ # Custom React Query hooks +β”œβ”€β”€ lib/ # API client & utilities +β”œβ”€β”€ pages/ # Route components +β”œβ”€β”€ services/ # API service functions +└── App.tsx # Main app component +``` + +## πŸ”— Integration + +- **Shared Types**: Uses `@shared/types` package for type safety +- **API Client**: Type-safe Axios client in `lib/api.ts` +- **React Query**: Automatic caching, background updates +- **Form Validation**: Zod schemas ensure data integrity + +Built with modern React patterns and best practices! πŸš€ diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js new file mode 100644 index 0000000..ea46802 --- /dev/null +++ b/apps/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]); diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..585ede3 --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Order Management System + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..ca78a40 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui" + }, + "dependencies": { + "@shared/types": "workspace:*", + "@tanstack/react-query": "^5.63.1", + "axios": "^1.7.9", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.54.2", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.1.3" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.6.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/ui": "^3.2.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "jsdom": "^27.0.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "npm:rolldown-vite@7.1.14", + "vitest": "^3.2.4" + } +} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/public/vite.svg b/apps/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..e5d3e6c --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,60 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import OrdersListPage from './pages/OrdersListPage'; +import OrderDetailsPage from './pages/OrderDetailsPage'; +import CreateOrderPage from './pages/CreateOrderPage'; +import EditOrderPage from './pages/EditOrderPage'; +import Layout from './components/Layout'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + staleTime: 5000, + }, + }, +}); + +function App() { + return ( + + + + + }> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} + +export default App; diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..27b2075 --- /dev/null +++ b/apps/frontend/src/__tests__/setup.ts @@ -0,0 +1,37 @@ +import { expect, afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import * as matchers from '@testing-library/jest-dom/matchers'; + +// Extend Vitest's expect with jest-dom matchers +expect.extend(matchers); + +// Cleanup despuΓ©s de cada test +afterEach(() => { + cleanup(); +}); + +// Mock IntersectionObserver +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} +} as unknown as typeof IntersectionObserver; + +// Mock window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/apps/frontend/src/assets/react.svg b/apps/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/components/ConfirmDialog.tsx b/apps/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..08cf336 --- /dev/null +++ b/apps/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,73 @@ +import { useState, type ReactNode } from 'react'; + +interface ConfirmDialogProps { + trigger: (onClick: () => void) => ReactNode; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void | Promise; +} + +export default function ConfirmDialog({ + trigger, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + onConfirm, +}: ConfirmDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = async () => { + setIsLoading(true); + try { + await onConfirm(); + setIsOpen(false); + } catch (error) { + console.error('Error in confirmation action:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + {trigger(() => setIsOpen(true))} + + {isOpen && ( +
+ {/* Backdrop */} +
!isLoading && setIsOpen(false)} + /> + + {/* Modal */} +
+

{title}

+

{message}

+ +
+ + +
+
+
+ )} + + ); +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..360bec2 --- /dev/null +++ b/apps/frontend/src/components/Layout.tsx @@ -0,0 +1,48 @@ +import { Outlet, Link, useLocation } from 'react-router-dom'; + +export default function Layout() { + const location = useLocation(); + + return ( +
+ + +
+ +
+ +
+
+

+ Full-Stack Order Management System Β· Built by Daniel Khadour +

+
+
+
+ ); +} diff --git a/apps/frontend/src/components/StatusBadge.tsx b/apps/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..a8e7991 --- /dev/null +++ b/apps/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,38 @@ +import type { OrderStatusType } from '@shared/types'; + +interface StatusBadgeProps { + status: OrderStatusType; +} + +const statusConfig: Record< + OrderStatusType, + { + label: string; + className: string; + } +> = { + PENDING: { + label: 'Pending', + className: 'bg-yellow-100 text-yellow-800 border-yellow-300', + }, + COMPLETED: { + label: 'Completed', + className: 'bg-green-100 text-green-800 border-green-300', + }, + CANCELLED: { + label: 'Cancelled', + className: 'bg-red-100 text-red-800 border-red-300', + }, +}; + +export default function StatusBadge({ status }: StatusBadgeProps) { + const config = statusConfig[status]; + + return ( + + {config.label} + + ); +} diff --git a/apps/frontend/src/hooks/__tests__/useOrders.test.ts b/apps/frontend/src/hooks/__tests__/useOrders.test.ts new file mode 100644 index 0000000..cdbb757 --- /dev/null +++ b/apps/frontend/src/hooks/__tests__/useOrders.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useOrders } from '../useOrders'; +import React, { type ReactNode } from 'react'; + +describe('useOrders Hook', () => { + let queryClient: QueryClient; + + // Helper to create wrapper with React Query + const createWrapper = () => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Don't retry in tests + }, + }, + }); + + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + }; + + beforeEach(() => { + // Clear all mocks before each test + if (queryClient) { + queryClient.clear(); + } + }); + + it('should return initial state correctly', () => { + const { result } = renderHook(() => useOrders({}), { + wrapper: createWrapper(), + }); + + // Initial state should be loading + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeNull(); + }); + + it('should accept pagination parameters', async () => { + const { result } = renderHook( + () => + useOrders({ + page: 2, + page_size: 20, + }), + { + wrapper: createWrapper(), + } + ); + + // Verify hook initializes correctly with parameters + expect(result.current.isLoading).toBe(true); + + // Wait for loading to finish (may fail if backend is not running) + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 5000 } + ); + }); + + it('should accept status filter', async () => { + const { result } = renderHook( + () => + useOrders({ + status: 'PENDING', + }), + { + wrapper: createWrapper(), + } + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 5000 } + ); + }); +}); diff --git a/apps/frontend/src/hooks/useOrders.ts b/apps/frontend/src/hooks/useOrders.ts new file mode 100644 index 0000000..262145e --- /dev/null +++ b/apps/frontend/src/hooks/useOrders.ts @@ -0,0 +1,80 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; +import { ordersApi } from '../services/orders.service'; +import type { CreateOrderDTO, UpdateOrderDTO, OrdersQuery } from '@shared/types'; + +// Query keys +export const orderKeys = { + all: ['orders'] as const, + lists: () => [...orderKeys.all, 'list'] as const, + list: (filters: Partial) => [...orderKeys.lists(), filters] as const, + details: () => [...orderKeys.all, 'detail'] as const, + detail: (id: string) => [...orderKeys.details(), id] as const, +}; + +// Get paginated orders +export const useOrders = (params: Partial = {}) => { + return useQuery({ + queryKey: orderKeys.list(params), + queryFn: () => ordersApi.getOrders(params), + }); +}; + +// Get single order +export const useOrder = (id: string) => { + return useQuery({ + queryKey: orderKeys.detail(id), + queryFn: () => ordersApi.getOrderById(id), + enabled: !!id, + }); +}; + +// Create order mutation +export const useCreateOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateOrderDTO) => ordersApi.createOrder(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: orderKeys.lists() }); + toast.success('Order created successfully!'); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to create order'); + }, + }); +}; + +// Update order mutation +export const useUpdateOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateOrderDTO }) => + ordersApi.updateOrder(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: orderKeys.lists() }); + queryClient.invalidateQueries({ queryKey: orderKeys.detail(variables.id) }); + toast.success('Order updated successfully!'); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to update order'); + }, + }); +}; + +// Delete order mutation +export const useDeleteOrder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => ordersApi.deleteOrder(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: orderKeys.lists() }); + toast.success('Order deleted successfully!'); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to delete order'); + }, + }); +}; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 0000000..2f339cc --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom component styles */ +@layer components { + .btn-primary { + @apply bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors font-medium; + } + + .btn-danger { + @apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors font-medium; + } + + .card { + @apply bg-white rounded-lg shadow-md p-6; + } + + .input { + @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent; + } + + .label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts new file mode 100644 index 0000000..d686b72 --- /dev/null +++ b/apps/frontend/src/lib/api.ts @@ -0,0 +1,21 @@ +import axios from 'axios'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error); + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 0000000..df655ea --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/frontend/src/pages/CreateOrderPage.tsx b/apps/frontend/src/pages/CreateOrderPage.tsx new file mode 100644 index 0000000..62b069e --- /dev/null +++ b/apps/frontend/src/pages/CreateOrderPage.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCreateOrder } from '../hooks/useOrders'; +import { OrderStatus, type CreateOrderDTO, type OrderStatusType } from '@shared/types'; + +export default function CreateOrderPage() { + const navigate = useNavigate(); + const createMutation = useCreateOrder(); + const [formData, setFormData] = useState({ + customer_name: '', + item: '', + quantity: 1, + status: OrderStatus.PENDING, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createMutation.mutateAsync(formData); + alert('Order created successfully!'); + navigate('/orders'); + } catch { + alert('Error creating order'); + } + }; + + return ( +
+

Create New Order

+ +
+
+
+ + setFormData({ ...formData, customer_name: e.target.value })} + className="input" + placeholder="John Doe" + /> +
+ +
+ + setFormData({ ...formData, item: e.target.value })} + className="input" + placeholder="Product name" + /> +
+ +
+ + { + const v = parseInt(e.target.value, 10); + setFormData({ ...formData, quantity: Number.isNaN(v) ? 1 : v }); + }} + className="input" + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/EditOrderPage.tsx b/apps/frontend/src/pages/EditOrderPage.tsx new file mode 100644 index 0000000..b61cbae --- /dev/null +++ b/apps/frontend/src/pages/EditOrderPage.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useOrder, useUpdateOrder } from '../hooks/useOrders'; +import { OrderStatus, type UpdateOrderDTO, type OrderStatusType } from '@shared/types'; + +export default function EditOrderPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: order, isLoading } = useOrder(id!); + const updateMutation = useUpdateOrder(); + + const [formData, setFormData] = useState({ + customer_name: '', + item: '', + quantity: 1, + status: OrderStatus.PENDING, + }); + + useEffect(() => { + if (order) { + setFormData({ + customer_name: order.customer_name, + item: order.item, + quantity: order.quantity, + status: order.status, + }); + } + }, [order]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await updateMutation.mutateAsync({ id: id!, data: formData }); + alert('Order updated successfully!'); + navigate(`/orders/${id}`); + } catch { + alert('Error updating order'); + } + }; + + if (isLoading) return
Loading...
; + if (!order) return
Order not found
; + + return ( +
+

Edit Order

+ +
+
+
+ + setFormData({ ...formData, customer_name: e.target.value })} + className="input" + /> +
+ +
+ + setFormData({ ...formData, item: e.target.value })} + className="input" + /> +
+ +
+ + { + const v = parseInt(e.target.value, 10); + setFormData({ ...formData, quantity: Number.isNaN(v) ? 1 : v }); + }} + className="input" + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/OrderDetailsPage.tsx b/apps/frontend/src/pages/OrderDetailsPage.tsx new file mode 100644 index 0000000..ef70b17 --- /dev/null +++ b/apps/frontend/src/pages/OrderDetailsPage.tsx @@ -0,0 +1,78 @@ +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useOrder, useDeleteOrder } from '../hooks/useOrders'; +import StatusBadge from '../components/StatusBadge'; +import ConfirmDialog from '../components/ConfirmDialog'; + +export default function OrderDetailsPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: order, isLoading, error } = useOrder(id!); + const deleteMutation = useDeleteOrder(); + + if (isLoading) return
Loading...
; + if (error || !order) return
Order not found
; + + return ( +
+
+

Order Details

+ + ← Back to List + +
+ +
+
+
+
Order ID
+
{order.id}
+
+
+
Customer Name
+
{order.customer_name}
+
+
+
Item
+
{order.item}
+
+
+
Quantity
+
{order.quantity}
+
+
+
Status
+
+ +
+
+
+
Created At
+
+ {new Date(order.created_at).toLocaleString()} +
+
+
+ +
+ + Edit Order + + ( + + )} + title="Delete Order" + message="Delete this order permanently?" + confirmLabel="Delete" + onConfirm={async () => { + await deleteMutation.mutateAsync(id!); + navigate('/orders'); + }} + /> +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/OrdersListPage.tsx b/apps/frontend/src/pages/OrdersListPage.tsx new file mode 100644 index 0000000..31fb7dc --- /dev/null +++ b/apps/frontend/src/pages/OrdersListPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useOrders, useDeleteOrder } from '../hooks/useOrders'; +import { OrderStatus, type OrderStatusType } from '@shared/types'; +import StatusBadge from '../components/StatusBadge'; +import ConfirmDialog from '../components/ConfirmDialog'; + +export default function OrdersListPage() { + const [page, setPage] = useState(1); + const [status, setStatus] = useState(''); + const pageSize = 10; + + const { data, isLoading, error } = useOrders({ + page, + page_size: pageSize, + ...(status && { status }), + }); + + const deleteMutation = useDeleteOrder(); + + if (isLoading) { + return ( +
+
Loading orders...
+
+ ); + } + + if (error) { + return ( +
+

Error loading orders. Make sure the backend is running!

+
+ ); + } + + return ( +
+
+

Orders

+
+ +
+
+ +
+
+ + + + + + + + + + + + + {data?.data.map((order) => ( + + + + + + + + + ))} + +
+ Customer + + Item + + Quantity + + Status + + Date + + Actions +
+ {order.customer_name} + + {order.item} + + {order.quantity} + + + + {new Date(order.created_at).toLocaleDateString()} + + + View + + + Edit + + ( + + )} + title="Delete Order" + message={`Delete order for ${order.customer_name}?`} + confirmLabel="Delete" + onConfirm={async () => { + await deleteMutation.mutateAsync(order.id); + }} + /> +
+
+ + {/* Pagination */} +
+
+ Page {data?.pagination.page} of {data?.pagination.total_pages} ({data?.pagination.total}{' '} + total orders) +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/pages/__tests__/OrdersListPage.test.tsx b/apps/frontend/src/pages/__tests__/OrdersListPage.test.tsx new file mode 100644 index 0000000..1ac7bd4 --- /dev/null +++ b/apps/frontend/src/pages/__tests__/OrdersListPage.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import OrdersListPage from '../OrdersListPage'; + +describe('OrdersListPage Component', () => { + // Helper to render with necessary providers + const renderWithProviders = (component: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return render( + + {component} + + ); + }; + + it('should render the component without errors', () => { + renderWithProviders(); + + // Verify component renders and shows loading + expect(screen.getByText(/Loading orders.../i)).toBeTruthy(); + }); + + it('should show loading state initially', () => { + renderWithProviders(); + + // Verify it shows loading + const loadingElement = screen.getByText(/Loading orders.../i); + expect(loadingElement).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/services/orders.service.ts b/apps/frontend/src/services/orders.service.ts new file mode 100644 index 0000000..7e55abb --- /dev/null +++ b/apps/frontend/src/services/orders.service.ts @@ -0,0 +1,43 @@ +import apiClient from '../lib/api'; +import type { + Order, + CreateOrderDTO, + UpdateOrderDTO, + PaginatedResponse, + ApiResponse, + OrdersQuery, +} from '@shared/types'; + +export const ordersApi = { + // Get paginated orders with optional filtering + getOrders: async (params: Partial = {}) => { + const { data } = await apiClient.get>>('/orders', { + params, + }); + return data.data; + }, + + // Get order by ID + getOrderById: async (id: string) => { + const { data } = await apiClient.get>(`/orders/${id}`); + return data.data; + }, + + // Create new order + createOrder: async (orderData: CreateOrderDTO) => { + const { data } = await apiClient.post>('/orders', orderData); + return data.data; + }, + + // Update existing order + updateOrder: async (id: string, orderData: UpdateOrderDTO) => { + const { data } = await apiClient.put>(`/orders/${id}`, orderData); + return data.data; + }, + + // Delete order + deleteOrder: async (id: string) => { + const { data } = await apiClient.delete>(`/orders/${id}`); + return data.data; + }, +}; diff --git a/apps/frontend/src/vite-env.d.ts b/apps/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..9bb54f9 --- /dev/null +++ b/apps/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js new file mode 100644 index 0000000..d21f1cd --- /dev/null +++ b/apps/frontend/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/apps/frontend/tsconfig.app.json b/apps/frontend/tsconfig.app.json new file mode 100644 index 0000000..ce66ac0 --- /dev/null +++ b/apps/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "src/vite-env.d.ts"] +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/apps/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/frontend/tsconfig.node.json b/apps/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/apps/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts new file mode 100644 index 0000000..4a5def4 --- /dev/null +++ b/apps/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 0000000..fe35c4a --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/__tests__/setup.ts', + css: true, + }, + resolve: { + alias: { + '@shared/types': path.resolve(__dirname, '../../packages/shared-types/src/index.ts'), + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..560b9f3 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "fullstack-order-management", + "version": "1.0.0", + "private": true, + "description": "Full-Stack TypeScript Order Management System - Technical Challenge", + "author": "Daniel Khadour", + "keywords": [ + "typescript", + "react", + "node", + "express", + "prisma", + "postgresql", + "fullstack" + ], + "scripts": { + "dev:backend": "pnpm --filter backend dev", + "dev:frontend": "pnpm --filter frontend dev", + "build:backend": "pnpm --filter backend build", + "build:frontend": "pnpm --filter frontend build", + "test:backend": "pnpm --filter backend test", + "test:frontend": "pnpm --filter frontend test", + "test": "pnpm test:backend && pnpm test:frontend", + "lint": "pnpm -r lint", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"" + }, + "devDependencies": { + "prettier": "^3.3.3", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } + } +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..09bfb64 --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,17 @@ +{ + "name": "@shared/types", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..6904489 --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; + +// ======================================== +// ENUMS +// ======================================== + +/** + * Order status enum + */ +export const OrderStatus = { + PENDING: 'PENDING', + COMPLETED: 'COMPLETED', + CANCELLED: 'CANCELLED', +} as const; + +export type OrderStatusType = (typeof OrderStatus)[keyof typeof OrderStatus]; + +// ======================================== +// ZOD SCHEMAS +// ======================================== + +/** + * Order status Zod schema + */ +export const OrderStatusSchema = z.enum(['PENDING', 'COMPLETED', 'CANCELLED']); + +/** + * Base Order schema (without id and timestamps) + */ +export const CreateOrderSchema = z.object({ + customer_name: z + .string() + .min(1, 'Customer name is required') + .max(255, 'Customer name must be less than 255 characters'), + item: z.string().min(1, 'Item is required').max(255, 'Item must be less than 255 characters'), + quantity: z + .number() + .int('Quantity must be an integer') + .positive('Quantity must be a positive number'), + status: OrderStatusSchema, +}); + +/** + * Update Order schema (all fields optional) + */ +export const UpdateOrderSchema = CreateOrderSchema.partial(); + +/** + * Complete Order schema (with id and timestamps) + */ +export const OrderSchema = CreateOrderSchema.extend({ + id: z.string().uuid('Invalid order ID format'), + created_at: z.string().datetime(), +}); + +/** + * Pagination query parameters schema + */ +export const PaginationSchema = z.object({ + page: z + .number() + .int('Page must be an integer') + .positive('Page must be a positive number') + .default(1), + page_size: z + .number() + .int('Page size must be an integer') + .positive('Page size must be a positive number') + .max(100, 'Page size cannot exceed 100') + .default(10), +}); + +/** + * Filter query parameters schema + */ +export const OrderFilterSchema = z.object({ + status: OrderStatusSchema.optional(), +}); + +/** + * Combined query parameters for orders list + */ +export const OrdersQuerySchema = PaginationSchema.merge(OrderFilterSchema); + +// ======================================== +// TYPESCRIPT TYPES (Inferred from Zod) +// ======================================== + +export type CreateOrderDTO = z.infer; +export type UpdateOrderDTO = z.infer; +export type Order = z.infer; +export type PaginationParams = z.infer; +export type OrderFilter = z.infer; +export type OrdersQuery = z.infer; + +// ======================================== +// API RESPONSE TYPES +// ======================================== + +/** + * Generic paginated response + */ +export interface PaginatedResponse { + data: T[]; + pagination: { + page: number; + page_size: number; + total: number; + total_pages: number; + }; +} + +/** + * Generic API success response + */ +export interface ApiResponse { + success: true; + data: T; + message?: string; +} + +/** + * API error response + */ +export interface ApiError { + success: false; + error: { + message: string; + code: string; + details?: unknown; + }; +} + +/** + * Type guard to check if response is an error + */ +export const isApiError = (response: unknown): response is ApiError => { + return ( + typeof response === 'object' && + response !== null && + 'success' in response && + response.success === false + ); +}; diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 0000000..5c42748 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e9b0dad --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' diff --git a/postman/Order_Management_API.postman_collection.json b/postman/Order_Management_API.postman_collection.json new file mode 100644 index 0000000..7658262 --- /dev/null +++ b/postman/Order_Management_API.postman_collection.json @@ -0,0 +1,449 @@ +{ + "info": { + "name": "Order Management API", + "description": "Complete API for Order Management System with pagination, filtering and CRUD operations", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Global pre-request script", + "console.log('πŸš€ Running Order Management API Tests');" + ], + "type": "text/javascript" + } + } + ], + "item": [ + { + "name": "Health Check", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Server is healthy', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… Response has correct structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('status', 'ok');", + " pm.expect(responseJson).to.have.property('message');", + "});", + "", + "console.log('βœ… Health check passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/health", + "host": ["{{BASE_URL}}"], + "path": ["health"] + } + } + }, + { + "name": "Get All Orders (Paginated)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Get orders returns 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… Response has correct pagination structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " pm.expect(responseJson).to.have.property('data');", + " pm.expect(responseJson.data).to.have.property('data');", + " pm.expect(responseJson.data).to.have.property('pagination');", + " ", + " const pagination = responseJson.data.pagination;", + " pm.expect(pagination).to.have.property('page');", + " pm.expect(pagination).to.have.property('page_size');", + " pm.expect(pagination).to.have.property('total');", + " pm.expect(pagination).to.have.property('total_pages');", + "});", + "", + "pm.test('βœ… Orders have correct structure', function () {", + " const responseJson = pm.response.json();", + " const orders = responseJson.data.data;", + " ", + " if (orders.length > 0) {", + " const firstOrder = orders[0];", + " pm.expect(firstOrder).to.have.property('id');", + " pm.expect(firstOrder).to.have.property('customer_name');", + " pm.expect(firstOrder).to.have.property('item');", + " pm.expect(firstOrder).to.have.property('quantity');", + " pm.expect(firstOrder).to.have.property('status');", + " pm.expect(firstOrder).to.have.property('created_at');", + " ", + " // Store first order ID for later tests", + " pm.environment.set('FIRST_ORDER_ID', firstOrder.id);", + " console.log('πŸ“ Stored order ID:', firstOrder.id);", + " }", + "});", + "", + "console.log('βœ… Get all orders test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/orders?page=1&page_size=10", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders"], + "query": [ + { + "key": "page", + "value": "1", + "description": "Page number (default: 1)" + }, + { + "key": "page_size", + "value": "10", + "description": "Items per page (default: 10, max: 100)" + }, + { + "key": "status", + "value": "", + "description": "Filter by status: PENDING, COMPLETED, CANCELLED", + "disabled": true + } + ] + } + } + }, + { + "name": "Get Orders - Filter by Status (Pending)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Filter by pending status returns 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… All returned orders have pending status', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " ", + " const orders = responseJson.data.data;", + " orders.forEach(function(order) {", + " pm.expect(order).to.have.property('status', 'PENDING');", + " });", + "});", + "", + "console.log('βœ… Filter pending test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/orders?status=PENDING", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders"], + "query": [ + { + "key": "status", + "value": "PENDING" + } + ] + } + } + }, + { + "name": "Get Orders - Filter by Status (Completed)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Filter by completed status returns 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… All returned orders have completed status', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " ", + " const orders = responseJson.data.data;", + " orders.forEach(function(order) {", + " pm.expect(order).to.have.property('status', 'COMPLETED');", + " });", + "});", + "", + "console.log('βœ… Filter completed test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/orders?status=COMPLETED", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders"], + "query": [ + { + "key": "status", + "value": "COMPLETED" + } + ] + } + } + }, + { + "name": "Get Order by ID", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Use the created order ID or first order ID", + "const createdOrderId = pm.environment.get('CREATED_ORDER_ID');", + "const firstOrderId = pm.environment.get('FIRST_ORDER_ID');", + "const orderId = createdOrderId || firstOrderId;", + "", + "if (orderId) {", + " pm.environment.set('ORDER_ID', orderId);", + " console.log('πŸ” Using order ID:', orderId);", + "} else {", + " console.log('⚠️ No order ID available, test may fail');", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Get order by ID returns 200', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… Order has correct structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " pm.expect(responseJson).to.have.property('data');", + " ", + " const order = responseJson.data;", + " pm.expect(order).to.have.property('id');", + " pm.expect(order).to.have.property('customer_name');", + " pm.expect(order).to.have.property('item');", + " pm.expect(order).to.have.property('quantity');", + " pm.expect(order).to.have.property('status');", + " pm.expect(order).to.have.property('created_at');", + "});", + "", + "console.log('βœ… Get order by ID test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/orders/{{ORDER_ID}}", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders", "{{ORDER_ID}}"] + } + } + }, + { + "name": "Create Order", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Order created successfully', function () {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test('βœ… Created order has correct structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " pm.expect(responseJson).to.have.property('data');", + " ", + " const order = responseJson.data;", + " pm.expect(order).to.have.property('id');", + " pm.expect(order).to.have.property('customer_name', 'John Doe');", + " pm.expect(order).to.have.property('item', 'MacBook Pro M3');", + " pm.expect(order).to.have.property('quantity', 2);", + " pm.expect(order).to.have.property('status', 'PENDING');", + " pm.expect(order).to.have.property('created_at');", + " ", + " // Store created order ID for update/delete tests", + " pm.environment.set('CREATED_ORDER_ID', order.id);", + " console.log('πŸ“ Created order ID:', order.id);", + "});", + "", + "console.log('βœ… Create order test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"customer_name\": \"John Doe\",\n \"item\": \"MacBook Pro M3\",\n \"quantity\": 2,\n \"status\": \"PENDING\"\n}" + }, + "url": { + "raw": "{{BASE_URL}}/api/orders", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders"] + } + } + }, + { + "name": "Update Order", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Use the created order ID for update", + "const createdOrderId = pm.environment.get('CREATED_ORDER_ID');", + "if (createdOrderId) {", + " pm.environment.set('ORDER_ID', createdOrderId);", + " console.log('πŸ”„ Updating order ID:', createdOrderId);", + "} else {", + " console.log('⚠️ No created order ID available for update');", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Order updated successfully', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… Updated order has correct structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " pm.expect(responseJson).to.have.property('data');", + " ", + " const order = responseJson.data;", + " pm.expect(order).to.have.property('id');", + " pm.expect(order).to.have.property('customer_name', 'John Doe Updated');", + " pm.expect(order).to.have.property('quantity', 5);", + " pm.expect(order).to.have.property('status', 'COMPLETED');", + "});", + "", + "console.log('βœ… Update order test passed');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"customer_name\": \"John Doe Updated\",\n \"quantity\": 5,\n \"status\": \"COMPLETED\"\n}" + }, + "url": { + "raw": "{{BASE_URL}}/api/orders/{{ORDER_ID}}", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders", "{{ORDER_ID}}"] + } + } + }, + { + "name": "Delete Order", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Use the created order ID for deletion", + "const createdOrderId = pm.environment.get('CREATED_ORDER_ID');", + "if (createdOrderId) {", + " pm.environment.set('ORDER_ID', createdOrderId);", + " console.log('πŸ—‘οΈ Deleting order ID:', createdOrderId);", + "} else {", + " console.log('⚠️ No created order ID available for deletion');", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('βœ… Order deleted successfully', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('βœ… Delete response has correct structure', function () {", + " const responseJson = pm.response.json();", + " pm.expect(responseJson).to.have.property('success', true);", + " pm.expect(responseJson).to.have.property('data');", + " pm.expect(responseJson.data).to.have.property('id');", + "});", + "", + "// Clean up environment variables", + "pm.environment.unset('CREATED_ORDER_ID');", + "pm.environment.unset('ORDER_ID');", + "console.log('βœ… Delete order test passed and variables cleaned up');" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/orders/{{ORDER_ID}}", + "host": ["{{BASE_URL}}"], + "path": ["api", "orders", "{{ORDER_ID}}"] + } + } + } + ] +} diff --git a/postman/Order_Management_Dev.postman_environment.json b/postman/Order_Management_Dev.postman_environment.json new file mode 100644 index 0000000..9cb7815 --- /dev/null +++ b/postman/Order_Management_Dev.postman_environment.json @@ -0,0 +1,19 @@ +{ + "id": "order-management-dev", + "name": "Order Management - Development", + "values": [ + { + "key": "BASE_URL", + "value": "http://localhost:3000", + "type": "default", + "enabled": true + }, + { + "key": "ORDER_ID", + "value": "", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..ae8b2d7 --- /dev/null +++ b/postman/README.md @@ -0,0 +1,81 @@ +# πŸ§ͺ Postman API Testing + +## πŸš€ Quick Setup + +### 1. Import Files + +1. Open **Postman** +2. Click **Import** +3. Drag these files: + - `Order_Management_API.postman_collection.json` + - `Order_Management_Dev.postman_environment.json` +4. Select **"Order Management - Development"** environment + +### 2. Run All Tests + +1. Click the **"Order Management API"** collection +2. Click **"Run"** button +3. Select all requests +4. Click **"Run Order Management API"** +5. Watch all tests pass! βœ… + +## πŸ“‹ What Gets Tested + +βœ… **Health Check** - Server status + +βœ… **Get All Orders** - Pagination & structure + +βœ… **Create Order** - CRUD functionality + +βœ… **Get Order by ID** - Individual retrieval + +βœ… **Update Order** - Modification + +βœ… **Filter by Status** - Pending & Completed + +βœ… **Delete Order** - Cleanup + +## 🎯 Expected Results + +When you run the collection: + +- **8/8 requests** successful +- **~20+ automated tests** passing +- **Full CRUD workflow** validated +- **Status filtering** working +- **Error handling** tested + +## πŸ”§ Environment Variables + +| Variable | Value | Description | +|--------------------|-------------------------|----------------------------| +| `BASE_URL` | `http://localhost:3000` | API server URL | +| `ORDER_ID` | _auto-set_ | Used for get/update/delete | +| `CREATED_ORDER_ID` | _auto-set_ | Tracks created orders | +| `FIRST_ORDER_ID` | _auto-set_ | First order from list | + +Variables are automatically managed during test execution. + +## πŸ“ Important: Order Status Values + +**⚠️ Status values must be UPPERCASE:** + +- βœ… `PENDING` (not "pending") +- βœ… `COMPLETED` (not "completed") +- βœ… `CANCELLED` (not "cancelled") + +This ensures type safety and consistency across the application. + +## πŸ§ͺ Manual Testing + +If you prefer individual requests: + +1. **Health Check** β†’ Verify server running +2. **Get All Orders** β†’ See existing data +3. **Create Order** β†’ Add new order +4. **Get Order by ID** β†’ Retrieve specific order +5. **Update Order** β†’ Modify order +6. **Filter Orders** β†’ Test status filtering +7. **Delete Order** β†’ Remove order + +**Ready to test!** πŸš€