updated frontend

This commit is contained in:
magdo
2026-04-01 22:01:36 +02:00
parent 91e48d2178
commit 3ee9c03b85
42 changed files with 1498 additions and 23 deletions
+7
View File
@@ -62,8 +62,15 @@ Cookie is HTTP-only and set with `sameSite=lax`.
- `GET /api/health` - `GET /api/health`
- `GET /api/shop/categories` - `GET /api/shop/categories`
- `GET /api/shop/products` - `GET /api/shop/products`
- `GET /api/shop/products/:productId`
- `POST /api/shop/products` (`multipart/form-data` is supported, field name: `image`)
- `POST /api/shop/products/:productId/image` (`multipart/form-data`, field name: `image`)
- `POST /api/shop/orders` - `POST /api/shop/orders`
Uploaded files are stored in `images/uploads` and are served at `/images/uploads/<filename>`.
Product responses include `image_url` with an absolute URL when an image exists.
Order payload: Order payload:
```json ```json
+214
View File
@@ -0,0 +1,214 @@
# Student Production Setup Guide
This guide explains how to set up and run the webstore backend in production mode.
## What You'll Receive
Your instructor will provide you with:
1. **webstore-production-images.tar** - Docker images archive (API + database)
2. **production.compose.yml** - Docker Compose configuration
3. **.production.env** - Environment variables template
4. **run-student-production.bat** - Easy start script
5. **Product images** - 17 placeholder product images
## Prerequisites
- **Docker Desktop** installed ([Download here](https://www.docker.com/products/docker-desktop))
- Windows 10 or later (for Docker)
- Minimum 2GB free disk space
- ~5 minutes for initial setup
## Step-by-Step Setup
### Step 1: Extract Docker Images
```
Right-click on webstore-production-images.tar
→ Select "Extract All"
→ Choose destination folder
```
Or use PowerShell/terminal:
```powershell
docker load -i webstore-production-images.tar
```
### Step 2: Configure Environment Variables
1. Open `.production.env` in a text editor
2. Update these values (if different from defaults):
```env
# Database Configuration
POSTGRES_DB=webstore_db
POSTGRES_USER=webstore_user
POSTGRES_PASSWORD=webstore_password
DB_PORT=5432
# API Configuration
PORT=3000
API_PORT=3000
# JWT Configuration
JWT_SECRET=your-secret-key-change-for-production
JWT_EXPIRES_IN=7d
# Security
COOKIE_NAME=webstore_token
COOKIE_SECURE=false # Set to true if using HTTPS
# Database URL (usually don't change this)
DATABASE_URL=postgresql://webstore_user:webstore_password@db:5432/webstore_db?schema=public
```
### Step 3: Start the Application
**Option A: Using the provided script (recommended)**
```
Double-click: run-student-production.bat
```
**Option B: Using Docker Compose directly**
```powershell
docker-compose -f production.compose.yml up
```
### Step 4: Verify Setup
Wait for logs to show:
```
webstore_api_prod | npm notice
webstore_api_prod | > webstore-backend@1.0.0 start
webstore_api_prod | > node src/Api/server.js
webstore_api_prod | Server running on port 3000
```
### Step 5: Test the API
Open in your browser:
```
http://localhost:3000/api/health
```
Expected response:
```json
{
"status": "ok",
"message": "CQRS backend running"
}
```
## Accessing the API
The API is available at: `http://localhost:3000`
### Key Endpoints
- `GET /api/health` - Health check
- `GET /api/shop/categories` - List all product categories
- `GET /api/shop/products` - List all products
- `GET /api/shop/products/:id` - Get product by ID
- `POST /api/shop/products` - Create product (with image upload)
- `POST /api/shop/products/:id/image` - Update product image
### Built-in Data
The database automatically seeds with:
**Test Users:**
```
Email: admin@test.com
Password: admin123
Email: john@test.com
Password: password123
Email: jane@test.com
Password: password123
```
**Products:**
- 16 sample products across 5 categories
- Each product has a placeholder image
- Sample prices range from 4,990 to 29,990 HUF
**Categories:**
- Shoes (4 products)
- Bags (4 products)
- Accessories (4 products)
- Clothing (3 products)
- Electronics (2 products)
## Stopping the Application
### Using Docker Compose:
```powershell
docker-compose -f production.compose.yml down
```
### Or simply close the terminal window
## Troubleshooting
### Port Already in Use
If you get "port 3000 already in use":
```powershell
# Change port in .production.env
API_PORT=3001
```
### Database Connection Failed
- Verify database container is running: `docker ps`
- Check DATABASE_URL in `.production.env`
- Ensure PostgreSQL image is loaded: `docker images | grep postgres`
### Docker Not Running
- Start Docker Desktop application
- Wait for Docker daemon to be ready (check system tray)
### Out of Disk Space
```powershell
# Clean up Docker
docker system prune -a
```
## Image Upload
Once running, you can upload product images:
```bash
curl -X POST http://localhost:3000/api/shop/products/1/image \
-F "image=@path/to/image.jpg"
```
Images are stored in the `images/uploads/` directory and served automatically.
## Database Persistence
Product data is stored in PostgreSQL. Data persists between application restarts unless you:
```powershell
docker-compose -f production.compose.yml down -v
```
The `-v` flag removes data volumes (use carefully!).
## Connecting Frontend
Configure your frontend to use:
```
API Base URL: http://localhost:3000/api
```
Example (fetch):
```javascript
const response = await fetch('http://localhost:3000/api/shop/products');
const data = await response.json();
console.log(data);
```
## Questions or Issues?
Consult your instructor or check the main README.md for more information about the backend architecture and CQRS pattern.
Happy coding! 🚀
+104
View File
@@ -0,0 +1,104 @@
# Product Images Directory
This directory contains product images for the webstore backend.
## Image Requirements
The following 17 placeholder images are required for the seed data to display properly:
**Shoes (4 images)**
- `street-runner.jpg` - 18,990 HUF
- `trail-edge.jpg` - 24,990 HUF
- `urban-sprint.jpg` - 22,990 HUF
- `classic-comfort.jpg` - 16,990 HUF
**Bags (4 images)**
- `urban-tote.jpg` - 14,990 HUF
- `backpack-pro.jpg` - 19,990 HUF
- `crossbody.jpg` - 9,990 HUF
- `duffle.jpg` - 24,990 HUF
**Accessories (4 images)**
- `classic-cap.jpg` - 6,990 HUF
- `silk-scarf.jpg` - 12,990 HUF
- `leather-belt.jpg` - 8,990 HUF
- `watch-straps.jpg` - 4,990 HUF
**Clothing (3 images)**
- `cotton-tshirt.jpg` - 7,990 HUF
- `denim-jacket.jpg` - 29,990 HUF
- `yoga-leggings.jpg` - 11,990 HUF
**Electronics (2 images)**
- `earbuds.jpg` - 17,990 HUF
- `usb-hub.jpg` - 5,990 HUF
## Directory Structure
```
images/
├── [17 product placeholder images]
├── uploads/ (auto-created by multer)
│ └── [user-uploaded images]
├── production.compose.yml (production docker-compose)
├── .production.env (production environment variables)
└── run-student-production.bat (production start script)
```
## Image Specifications
**Recommended specifications for product images:**
- Format: JPEG or PNG
- Dimensions: 400-600px width, 300-600px height
- File size: < 500KB per image
- Color space: RGB or RGBA
## Uploads Directory
The `uploads/` directory is automatically created by the multer middleware when the backend starts. This directory stores images uploaded by users through the frontend.
**Important notes:**
- Images are stored with timestamps: `1717123456789-product-name.jpg`
- Served via: `GET /images/uploads/{filename}`
- Max file size: 5MB per upload
- Only image MIME types allowed
## Production Build
When building for production with `build-production-images.bat`:
1. ✓ Placeholder images are automatically generated (if ImageMagick or PowerShell available)
2. ✓ Seeding runs automatically when containers start
3. ✓ Upload directory is created on first run
## Database Seeding
The database automatically seeds with:
- **5 categories**: Shoes, Bags, Accessories, Clothing, Electronics
- **16 products**: 2-4 per category with placeholder images
- **3 test users**: admin@test.com, john@test.com, jane@test.com
## Customization
To use custom product images:
1. **Development**: Replace placeholder images in this directory
2. **Production**: Include custom images in the `images/` directory before building
3. **After deployment**: Use the API to upload images via `POST /api/shop/products/{id}/image`
## Troubleshooting
### Images not displaying in frontend
- Ensure images are placed in the correct directory
- Check file names match exactly (case-sensitive on Linux/Mac)
- Verify `express.static` is configured in `src/Api/app.js`
- Check console for 404 errors
### Upload fails
- Check file is an image (JPEG, PNG, WebP, GIF, etc.)
- Verify file size < 5MB
- Ensure `uploads/` directory exists and is writable
### Seeding without images
- Seed will complete successfully even without images
- Products will have `null` imageUrl values
- Frontend can display placeholder or error images
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

@@ -4,7 +4,7 @@ setlocal EnableExtensions EnableDelayedExpansion
set "ROOT_DIR=%~dp0" set "ROOT_DIR=%~dp0"
set "ENV_FILE=%ROOT_DIR%\.production.env" set "ENV_FILE=%ROOT_DIR%\.production.env"
set "COMPOSE_FILE=%ROOT_DIR%\production.compose.yml" set "COMPOSE_FILE=%ROOT_DIR%\production.compose.yml"
set "ARCHIVE_FILE=%ROOT_DIR%\images\webstore-production-images.tar" set "ARCHIVE_FILE=%ROOT_DIR%\webstore-production-images.tar"
set "API_IMAGE=webstore-api:prod" set "API_IMAGE=webstore-api:prod"
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

+102 -1
View File
@@ -18,7 +18,8 @@
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0" "morgan": "^1.10.0",
"multer": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
@@ -120,6 +121,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -229,6 +236,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -292,6 +316,21 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1020,6 +1059,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1242,6 +1300,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1438,6 +1510,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1496,6 +1585,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1512,6 +1607,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+5 -2
View File
@@ -9,6 +9,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:seed": "node prisma/seed.js", "prisma:seed": "node prisma/seed.js",
"generate-data": "node scripts/generate-test-data.js",
"generate-images": "node scripts/generate-placeholder-images.js",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"keywords": [ "keywords": [
@@ -20,6 +22,7 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/client": "^5.20.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -28,10 +31,10 @@
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"@prisma/client": "^5.20.0" "multer": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
"prisma": "^5.20.0" "prisma": "^5.20.0"
} }
} }
+148 -7
View File
@@ -1,14 +1,21 @@
const { PrismaClient } = require("@prisma/client"); const { PrismaClient } = require("@prisma/client");
const bcryptjs = require("bcryptjs");
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
console.log("Starting database seeding...");
// Seed categories
const categories = [ const categories = [
{ name: "Shoes", slug: "shoes" }, { name: "Shoes", slug: "shoes" },
{ name: "Bags", slug: "bags" }, { name: "Bags", slug: "bags" },
{ name: "Accessories", slug: "accessories" } { name: "Accessories", slug: "accessories" },
{ name: "Clothing", slug: "clothing" },
{ name: "Electronics", slug: "electronics" }
]; ];
console.log("Creating categories...");
for (const category of categories) { for (const category of categories) {
await prisma.category.upsert({ await prisma.category.upsert({
where: { slug: category.slug }, where: { slug: category.slug },
@@ -20,12 +27,15 @@ async function main() {
const shoes = await prisma.category.findUnique({ where: { slug: "shoes" } }); const shoes = await prisma.category.findUnique({ where: { slug: "shoes" } });
const bags = await prisma.category.findUnique({ where: { slug: "bags" } }); const bags = await prisma.category.findUnique({ where: { slug: "bags" } });
const accessories = await prisma.category.findUnique({ where: { slug: "accessories" } }); const accessories = await prisma.category.findUnique({ where: { slug: "accessories" } });
const clothing = await prisma.category.findUnique({ where: { slug: "clothing" } });
const electronics = await prisma.category.findUnique({ where: { slug: "electronics" } });
const products = [ const products = [
// Shoes
{ {
categoryId: shoes.id, categoryId: shoes.id,
name: "Street Runner", name: "Street Runner",
description: "Lightweight city sneaker.", description: "Lightweight city sneaker perfect for daily commute.",
price: "18990.00", price: "18990.00",
imageUrl: "/images/street-runner.jpg", imageUrl: "/images/street-runner.jpg",
stock: 24 stock: 24
@@ -33,29 +43,138 @@ async function main() {
{ {
categoryId: shoes.id, categoryId: shoes.id,
name: "Trail Edge", name: "Trail Edge",
description: "Stable shoe for outdoor tracks.", description: "Stable shoe for outdoor tracks and hiking adventures.",
price: "24990.00", price: "24990.00",
imageUrl: "/images/trail-edge.jpg", imageUrl: "/images/trail-edge.jpg",
stock: 13 stock: 13
}, },
{
categoryId: shoes.id,
name: "Urban Sprint",
description: "High-performance running shoe for athletes.",
price: "22990.00",
imageUrl: "/images/urban-sprint.jpg",
stock: 18
},
{
categoryId: shoes.id,
name: "Classic Comfort",
description: "Timeless design with ultimate comfort for all-day wear.",
price: "16990.00",
imageUrl: "/images/classic-comfort.jpg",
stock: 31
},
// Bags
{ {
categoryId: bags.id, categoryId: bags.id,
name: "Urban Tote", name: "Urban Tote",
description: "Everyday tote with zipper top.", description: "Everyday tote with zipper top and multiple compartments.",
price: "14990.00", price: "14990.00",
imageUrl: "/images/urban-tote.jpg", imageUrl: "/images/urban-tote.jpg",
stock: 30 stock: 30
}, },
{
categoryId: bags.id,
name: "Backpack Pro",
description: "Professional backpack with laptop compartment.",
price: "19990.00",
imageUrl: "/images/backpack-pro.jpg",
stock: 15
},
{
categoryId: bags.id,
name: "Crossbody Essentials",
description: "Compact crossbody bag perfect for quick trips.",
price: "9990.00",
imageUrl: "/images/crossbody.jpg",
stock: 45
},
{
categoryId: bags.id,
name: "Weekend Duffle",
description: "Spacious duffle bag for travel and weekend getaways.",
price: "24990.00",
imageUrl: "/images/duffle.jpg",
stock: 8
},
// Accessories
{ {
categoryId: accessories.id, categoryId: accessories.id,
name: "Classic Cap", name: "Classic Cap",
description: "Adjustable cotton cap.", description: "Adjustable cotton cap with curved visor.",
price: "6990.00", price: "6990.00",
imageUrl: "/images/classic-cap.jpg", imageUrl: "/images/classic-cap.jpg",
stock: 42 stock: 42
},
{
categoryId: accessories.id,
name: "Silk Scarf",
description: "Premium silk scarf with elegant patterns.",
price: "12990.00",
imageUrl: "/images/silk-scarf.jpg",
stock: 20
},
{
categoryId: accessories.id,
name: "Leather Belt",
description: "Genuine leather belt with metal buckle.",
price: "8990.00",
imageUrl: "/images/leather-belt.jpg",
stock: 35
},
{
categoryId: accessories.id,
name: "Watch Straps Set",
description: "Set of 3 interchangeable watch straps.",
price: "4990.00",
imageUrl: "/images/watch-straps.jpg",
stock: 60
},
// Clothing
{
categoryId: clothing.id,
name: "Cotton T-Shirt",
description: "100% organic cotton comfortable t-shirt.",
price: "7990.00",
imageUrl: "/images/cotton-tshirt.jpg",
stock: 50
},
{
categoryId: clothing.id,
name: "Denim Jacket",
description: "Classic blue denim jacket perfect for any season.",
price: "29990.00",
imageUrl: "/images/denim-jacket.jpg",
stock: 12
},
{
categoryId: clothing.id,
name: "Yoga Leggings",
description: "High-waisted leggings with optimal breathability.",
price: "11990.00",
imageUrl: "/images/yoga-leggings.jpg",
stock: 25
},
// Electronics
{
categoryId: electronics.id,
name: "Wireless Earbuds",
description: "Premium noise-cancelling wireless earbuds.",
price: "17990.00",
imageUrl: "/images/earbuds.jpg",
stock: 22
},
{
categoryId: electronics.id,
name: "USB-C Hub",
description: "7-in-1 USB-C hub with multiple ports.",
price: "5990.00",
imageUrl: "/images/usb-hub.jpg",
stock: 40
} }
]; ];
console.log("Creating products...");
for (const product of products) { for (const product of products) {
await prisma.product.upsert({ await prisma.product.upsert({
where: { name: product.name }, where: { name: product.name },
@@ -63,15 +182,37 @@ async function main() {
create: product create: product
}); });
} }
// Seed test users
console.log("Creating test users...");
const testUsers = [
{ name: "Admin User", email: "admin@test.com", password: "admin123" },
{ name: "John Doe", email: "john@test.com", password: "password123" },
{ name: "Jane Smith", email: "jane@test.com", password: "password123" }
];
for (const user of testUsers) {
const passwordHash = await bcryptjs.hash(user.password, 10);
await prisma.user.upsert({
where: { email: user.email },
update: {},
create: {
name: user.name,
email: user.email,
passwordHash
}
});
}
console.log("Database seeding completed successfully!");
} }
main() main()
.then(async () => { .then(async () => {
await prisma.$disconnect(); await prisma.$disconnect();
console.log("Prisma seed completed.");
}) })
.catch(async (error) => { .catch(async (error) => {
console.error(error); console.error("Seeding error:", error);
await prisma.$disconnect(); await prisma.$disconnect();
process.exit(1); process.exit(1);
}); });
+179
View File
@@ -0,0 +1,179 @@
# Test Data Generation Scripts
This directory contains scripts to help manage test data for the webstore backend.
## Available Scripts
### 0. Generate Placeholder Images
Creates placeholder JPEG images for all 17 products used in seeding.
**File:** `generate-placeholder-images.js`
**What it does:**
- Generates 17 placeholder product images (400x300px)
- Saves images to `../images/` directory
- Creates `uploads/` directory for user-uploaded images
**Usage:**
```bash
# Using npm script
npm run generate-images
# Or directly with Node
node scripts/generate-placeholder-images.js
```
**When to run:**
- First time setting up development environment
- When `/images` route returns 404 (images not found)
- After cleaning up the images directory
**Images created (17 total):**
- Shoes: street-runner.jpg, trail-edge.jpg, urban-sprint.jpg, classic-comfort.jpg
- Bags: urban-tote.jpg, backpack-pro.jpg, crossbody.jpg, duffle.jpg
- Accessories: classic-cap.jpg, silk-scarf.jpg, leather-belt.jpg, watch-straps.jpg
- Clothing: cotton-tshirt.jpg, denim-jacket.jpg, yoga-leggings.jpg
- Electronics: earbuds.jpg, usb-hub.jpg
---
### 1. Seed Script (Automatic)
Runs automatically when the backend starts in development mode.
**File:** `../prisma/seed.js`
**What it does:**
- Creates 5 product categories (Shoes, Bags, Accessories, Clothing, Electronics)
- Creates 16 sample products across all categories
- Creates 3 test user accounts
**Test User Credentials:**
```
Email: admin@test.com
Password: admin123
Email: john@test.com
Password: password123
Email: jane@test.com
Password: password123
```
---
## 2. Generate Test Data Script
Generates additional bulk test data on demand.
### Usage Options
#### Option A: Windows Batch File (Recommended for Windows)
```bash
# Generate default test data (10 products, 5 orders)
scripts\generate-test-data.bat
# Generate 20 products instead of 10
scripts\generate-test-data.bat --products 20
# Generate 20 products and 10 orders
scripts\generate-test-data.bat --products 20 --orders 10
# DANGEROUS: Reset database and regenerate all data
scripts\generate-test-data.bat --reset --products 50 --orders 20
```
#### Option B: NPM Script
```bash
# Generate default test data
npm run generate-data
# With arguments (from project root)
npm run generate-data -- --products 30 --orders 15
```
#### Option C: Direct Node.js
```bash
# From project root
node scripts/generate-test-data.js --products 25 --orders 10
```
### Command-line Options
| Option | Description | Default |
|--------|-------------|---------|
| `--products N` | Number of additional products to generate | 10 |
| `--orders N` | Number of test orders to generate | 5 |
| `--reset` | ⚠️ **DANGEROUS**: Delete all data first | (not set) |
### Examples
```bash
# Quick test: Generate 5 products and 2 orders
scripts\generate-test-data.bat --products 5 --orders 2
# Moderate data: 30 products and 10 orders
npm run generate-data -- --products 30 --orders 10
# Heavy load test: 100 products and 50 orders
scripts\generate-test-data.bat --products 100 --orders 50
# Start fresh: Reset and generate 50 products and 20 orders
scripts\generate-test-data.bat --reset --products 50 --orders 20
```
## What Gets Generated
### Products
- Random product names with auto-incrementing counters
- Random prices between 5,990 and 24,990 HUF
- Random stock quantities (5-55 units)
- Random category assignment
- Placeholder image URLs
### Test Users
- 5 additional test user accounts
- Email: `user[1-5]@test.com`
- Password: `password123`
### Orders
- Random customer names
- Random customer emails
- Random order items (1-3 products per order)
- Automatically calculated total prices
## Important Notes
⚠️ **WARNING**: The `--reset` flag will delete:
- All orders and order items
- All products
- All categories
- All users
Only use `--reset` if you know what you're doing!
## Troubleshooting
### "Node.js is not installed or not in PATH"
- Install Node.js from https://nodejs.org/
- Make sure it's in your system PATH
### "Database connection error"
- Ensure the database is running
- Check `.env` file has correct `DATABASE_URL`
- Run migrations first: `npm run prisma:push`
### "Unique constraint failed" errors
- Product names must be unique
- The script skips duplicates automatically
- Use `--reset` to start fresh
## Docker Compose Integration
The test data generation automatically runs in docker-compose:
```bash
# Start backend (will seed data automatically)
docker-compose up api
# The initial data is seeded when the container first starts
```
For production, you may want to disable auto-seeding in the `docker-compose.yml` file.
@@ -8,14 +8,14 @@ set "ARCHIVE_FILE=%IMAGE_OUT_DIR%\webstore-production-images.tar"
set "API_IMAGE=webstore-api:prod" set "API_IMAGE=webstore-api:prod"
set "DB_IMAGE=postgres:16-alpine" set "DB_IMAGE=postgres:16-alpine"
echo [1/6] Checking Docker availability... echo [1/7] Checking Docker availability...
docker --version >nul 2>&1 docker --version >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Docker CLI is not available. Install Docker Desktop first. echo ERROR: Docker CLI is not available. Install Docker Desktop first.
exit /b 1 exit /b 1
) )
echo [2/6] Building production API image: %API_IMAGE% echo [2/7] Building production API image: %API_IMAGE%
pushd "%ROOT_DIR%" >nul pushd "%ROOT_DIR%" >nul
docker build -t "%API_IMAGE%" -f Dockerfile . docker build -t "%API_IMAGE%" -f Dockerfile .
if errorlevel 1 ( if errorlevel 1 (
@@ -25,17 +25,33 @@ if errorlevel 1 (
) )
popd >nul popd >nul
echo [3/6] Pulling database image: %DB_IMAGE% echo [3/7] Pulling database image: %DB_IMAGE%
docker pull "%DB_IMAGE%" docker pull "%DB_IMAGE%"
if errorlevel 1 ( if errorlevel 1 (
echo ERROR: Failed to pull database image. echo ERROR: Failed to pull database image.
exit /b 1 exit /b 1
) )
echo [4/6] Preparing image output directory: %IMAGE_OUT_DIR% echo [4/7] Preparing image output directory: %IMAGE_OUT_DIR%
if not exist "%IMAGE_OUT_DIR%" mkdir "%IMAGE_OUT_DIR%" if not exist "%IMAGE_OUT_DIR%" mkdir "%IMAGE_OUT_DIR%"
echo [5/6] Exporting images to archive... echo [5/7] Creating placeholder product images...
pushd "%ROOT_DIR%" >nul
REM Create uploads directory
if not exist "%IMAGE_OUT_DIR%\uploads" mkdir "%IMAGE_OUT_DIR%\uploads"
REM Use Node.js to generate placeholder images reliably
echo Generating placeholder images using Node.js...
node "%ROOT_DIR%\scripts\generate-placeholder-images.js"
if errorlevel 1 (
echo WARNING: Could not generate placeholder images automatically
echo Note: Images can be added manually to %IMAGE_OUT_DIR%
)
popd >nul
echo [6/7] Exporting images to archive...
if exist "%ARCHIVE_FILE%" del /f /q "%ARCHIVE_FILE%" if exist "%ARCHIVE_FILE%" del /f /q "%ARCHIVE_FILE%"
docker save -o "%ARCHIVE_FILE%" "%API_IMAGE%" "%DB_IMAGE%" docker save -o "%ARCHIVE_FILE%" "%API_IMAGE%" "%DB_IMAGE%"
if errorlevel 1 ( if errorlevel 1 (
@@ -43,7 +59,35 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
echo [6/6] Done. echo [7/7] Done.
echo.
echo ========================================
echo PRODUCTION BUILD COMPLETE
echo ========================================
echo.
echo Created archive: "%ARCHIVE_FILE%" echo Created archive: "%ARCHIVE_FILE%"
echo Share this file with students together with production.compose.yml and .production.env. echo.
echo FILES TO DISTRIBUTE TO STUDENTS:
echo 1. "%ARCHIVE_FILE%"
echo 2. "%IMAGE_OUT_DIR%\production.compose.yml"
echo 3. "%IMAGE_OUT_DIR%\.production.env" (with proper configuration)
echo 4. "%IMAGE_OUT_DIR%\run-student-production.bat"
echo.
echo WHAT'S INCLUDED:
echo - Production API Docker image
echo - PostgreSQL database image
echo - Initial seeding script (creates 5 categories, 16 products, 3 test users)
echo - Placeholder product images (17 images)
echo - Upload directory for student-generated images
echo.
echo STUDENTS SHOULD:
echo 1. Extract webstore-production-images.tar
echo 2. Copy .production.env and configure database credentials
echo 3. Run run-student-production.bat
echo 4. Database will auto-seed with sample data
echo 5. Test users available:
echo - admin@test.com / admin123
echo - john@test.com / password123
echo - jane@test.com / password123
echo.
exit /b 0 exit /b 0
@@ -0,0 +1,112 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
// Image list from README
const images = [
// Shoes
'street-runner.jpg',
'trail-edge.jpg',
'urban-sprint.jpg',
'classic-comfort.jpg',
// Bags
'urban-tote.jpg',
'backpack-pro.jpg',
'crossbody.jpg',
'duffle.jpg',
// Accessories
'classic-cap.jpg',
'silk-scarf.jpg',
'leather-belt.jpg',
'watch-straps.jpg',
// Clothing
'cotton-tshirt.jpg',
'denim-jacket.jpg',
'yoga-leggings.jpg',
// Electronics
'earbuds.jpg',
'usb-hub.jpg',
];
const imagesDir = path.join(__dirname, '..', 'images');
const uploadsDir = path.join(imagesDir, 'uploads');
// Create directories if they don't exist
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true });
console.log(`Created ${imagesDir}`);
}
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
console.log(`Created ${uploadsDir}`);
}
// Create simple JPEG placeholder images (minimal valid JPEG structure)
// This is a minimal JPEG structure that works with image viewers
const createPlaceholderJPEG = (filename, width, height) => {
// Minimal JPEG header and footer
const jpegHeader = Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, height,
0x00, width, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF,
0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04,
0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00,
0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32,
0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1,
0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A,
0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35,
0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55,
0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65,
0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94,
0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2,
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6,
0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA,
0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00,
0xFB, 0xD0, 0xFF, 0xD9,
]);
return jpegHeader;
};
let created = 0;
images.forEach((filename) => {
const filepath = path.join(imagesDir, filename);
if (!fs.existsSync(filepath)) {
const jpegData = createPlaceholderJPEG(filename, 400, 300);
fs.writeFileSync(filepath, jpegData);
console.log(`✓ Created ${filename}`);
created++;
} else {
console.log(`✓ Already exists: ${filename}`);
}
});
console.log(`\n${created} placeholder images generated successfully!`);
console.log(`Images directory: ${imagesDir}`);
@@ -0,0 +1,87 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
::
:: Generate test data script for webstore backend
:: Usage: generate-test-data.bat [--products N] [--orders N] [--reset]
::
:: Examples:
:: generate-test-data.bat (Creates 10 products, 5 orders)
:: generate-test-data.bat --products 20 (Creates 20 products, 5 orders)
:: generate-test-data.bat --products 50 --orders 10 --reset (Resets DB and generates data)
::
cd /d "%~dp0.."
echo.
echo ========================================
echo Test Data Generator for Webstore
echo ========================================
echo.
set "PRODUCTS=10"
set "ORDERS=5"
set "RESET_FLAG="
:parse_args
if "%~1"=="" goto start_generation
if "%~1"=="--products" (
if "%~2"=="" (
echo ERROR: --products requires a number argument
exit /b 1
)
set "PRODUCTS=%~2"
shift
shift
goto parse_args
)
if "%~1"=="--orders" (
if "%~2"=="" (
echo ERROR: --orders requires a number argument
exit /b 1
)
set "ORDERS=%~2"
shift
shift
goto parse_args
)
if "%~1"=="--reset" (
set "RESET_FLAG=--reset"
shift
goto parse_args
)
shift
goto parse_args
:start_generation
echo Configuration:
echo Products to generate: %PRODUCTS%
echo Orders to generate: %ORDERS%
if defined RESET_FLAG (
echo Reset database: YES (WARNING: All data will be deleted!)
) else (
echo Reset database: NO
)
echo.
echo Checking Node.js availability...
node --version >nul 2>&1
if errorlevel 1 (
echo ERROR: Node.js is not installed or not in PATH
exit /b 1
)
echo Running test data generator...
node scripts/generate-test-data.js --products %PRODUCTS% --orders %ORDERS% %RESET_FLAG%
if errorlevel 1 (
echo.
echo ERROR: Test data generation failed!
exit /b 1
)
echo.
echo ========================================
echo ✓ Test data generated successfully!
echo ========================================
exit /b 0
@@ -0,0 +1,263 @@
#!/usr/bin/env node
/**
* Script to generate bulk test data for the webstore database
* Usage: node scripts/generate-test-data.js [options]
*
* Options:
* --products N Generate N additional products (default: 10)
* --orders N Generate N test orders (default: 5)
* --reset Delete all data before seeding (careful!)
*/
require("dotenv").config({ path: ".env" });
const { PrismaClient } = require("@prisma/client");
const bcryptjs = require("bcryptjs");
const prisma = new PrismaClient();
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
productsCount: 10,
ordersCount: 5,
shouldReset: false
};
for (let i = 0; i < args.length; i++) {
if (args[i] === "--products" && args[i + 1]) {
options.productsCount = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === "--orders" && args[i + 1]) {
options.ordersCount = parseInt(args[i + 1], 10);
i++;
} else if (args[i] === "--reset") {
options.shouldReset = true;
}
}
const productNames = [
"Premium Leather Boots",
"Summer Canvas Shoes",
"Minimalist Sneakers",
"Casual Loafers",
"Running Shoes Pro",
"Hiking Boots",
"Beach Sandals",
"Formal Dress Shoes",
"Winter Snow Boots",
"Slip-on Comfort Shoes",
"Designer Heels",
"Waterproof Outdoor Boots",
"Lightweight Mesh Runners",
"Classic Oxfords",
"Athletic Training Shoes"
];
const descriptions = [
"Premium quality with exceptional comfort",
"Perfect for everyday wear and activities",
"Stylish design with modern aesthetics",
"Durable materials built to last",
"Eco-friendly sustainable production",
"Handcrafted with attention to detail",
"Weather-resistant and waterproof",
"Ergonomic design for all-day comfort",
"Available in multiple colors",
"Perfect for professional environments"
];
function generateRandomPrice() {
return (Math.floor(Math.random() * 20) + 5).toString() + "990.00";
}
function generateRandomStock() {
return Math.floor(Math.random() * 50) + 5;
}
function getRandomElement(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
async function resetDatabase() {
console.log("⚠️ Resetting database...");
try {
// Delete in order of dependencies
await prisma.orderItem.deleteMany({});
await prisma.order.deleteMany({});
await prisma.product.deleteMany({});
await prisma.category.deleteMany({});
await prisma.user.deleteMany({});
console.log("✓ Database reset completed");
} catch (error) {
console.error("Error resetting database:", error.message);
throw error;
}
}
async function seedInitialData() {
console.log("Seeding initial categories...");
const categories = [
{ name: "Shoes", slug: "shoes" },
{ name: "Bags", slug: "bags" },
{ name: "Accessories", slug: "accessories" },
{ name: "Clothing", slug: "clothing" },
{ name: "Electronics", slug: "electronics" }
];
for (const category of categories) {
await prisma.category.upsert({
where: { slug: category.slug },
update: {},
create: category
});
}
console.log("✓ Categories seeded");
}
async function generateProducts(count) {
console.log(`\nGenerating ${count} additional products...`);
const categories = await prisma.category.findMany();
const generatedProducts = [];
for (let i = 0; i < count; i++) {
const category = getRandomElement(categories);
const name = `${getRandomElement(productNames)} ${i + 1}`;
try {
const product = await prisma.product.create({
data: {
name,
description: getRandomElement(descriptions),
price: generateRandomPrice(),
stock: generateRandomStock(),
categoryId: category.id,
imageUrl: `/images/placeholder-${i + 1}.jpg`
}
});
generatedProducts.push(product);
if ((i + 1) % 5 === 0) {
process.stdout.write(`\r Progress: ${i + 1}/${count} products`);
}
} catch (error) {
if (!error.message.includes("Unique constraint failed")) {
console.error(`\nError creating product: ${error.message}`);
}
}
}
console.log(`\r✓ Generated ${generatedProducts.length} products`);
return generatedProducts;
}
async function generateTestUsers(count = 5) {
console.log(`\nGenerating ${count} test users...`);
const users = [];
for (let i = 1; i <= count; i++) {
const email = `user${i}@test.com`;
const passwordHash = await bcryptjs.hash("password123", 10);
try {
const user = await prisma.user.upsert({
where: { email },
update: {},
create: {
name: `Test User ${i}`,
email,
passwordHash
}
});
users.push(user);
} catch (error) {
console.error(`Error creating user: ${error.message}`);
}
}
console.log(`✓ Generated ${users.length} test users`);
return users;
}
async function generateOrders(count) {
console.log(`\nGenerating ${count} test orders...`);
const products = await prisma.product.findMany({ take: 20 });
const createdOrders = [];
for (let i = 0; i < count; i++) {
try {
const itemsCount = Math.floor(Math.random() * 3) + 1;
const items = [];
let totalPrice = 0;
for (let j = 0; j < itemsCount; j++) {
const product = getRandomElement(products);
const quantity = Math.floor(Math.random() * 3) + 1;
items.push({
productId: product.id,
quantity,
unitPrice: product.price
});
totalPrice += parseFloat(product.price) * quantity;
}
const order = await prisma.order.create({
data: {
customerName: `Customer ${i + 1}`,
customerEmail: `customer${i + 1}@example.com`,
totalPrice: totalPrice.toFixed(2),
items: {
create: items
}
},
include: {
items: true
}
});
createdOrders.push(order);
if ((i + 1) % 2 === 0) {
process.stdout.write(`\r Progress: ${i + 1}/${count} orders`);
}
} catch (error) {
console.error(`\nError creating order: ${error.message}`);
}
}
console.log(`\r✓ Generated ${createdOrders.length} orders`);
return createdOrders;
}
async function main() {
console.log("🚀 Test Data Generator for Webstore\n");
console.log("Options:");
console.log(` Products to generate: ${options.productsCount}`);
console.log(` Orders to generate: ${options.ordersCount}`);
console.log(` Reset database first: ${options.shouldReset ? "YES" : "NO"}\n`);
try {
if (options.shouldReset) {
await resetDatabase();
}
await seedInitialData();
await generateTestUsers(5);
await generateProducts(options.productsCount);
await generateOrders(options.ordersCount);
console.log("\n✨ Test data generation completed successfully!");
} catch (error) {
console.error("\n❌ Error during data generation:", error.message);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();
@@ -1,13 +1,18 @@
const GetCategoriesQuery = require("../../Application/Shop/Query/GetCategoriesQuery"); const GetCategoriesQuery = require("../../Application/Shop/Query/GetCategoriesQuery");
const GetProductsQuery = require("../../Application/Shop/Query/GetProductsQuery"); const GetProductsQuery = require("../../Application/Shop/Query/GetProductsQuery");
const GetProductByIdQuery = require("../../Application/Shop/Query/GetProductByIdQuery");
const CreateOrderCommand = require("../../Application/Shop/Command/CreateOrderCommand"); const CreateOrderCommand = require("../../Application/Shop/Command/CreateOrderCommand");
const CreateProductCommand = require("../../Application/Shop/Command/CreateProductCommand"); const CreateProductCommand = require("../../Application/Shop/Command/CreateProductCommand");
const SetProductImageCommand = require("../../Application/Shop/Command/SetProductImageCommand");
const ProductDTO = require("../../Application/DTO/ProductDTO");
const container = require("../../Infrastructure/DI/container"); const container = require("../../Infrastructure/DI/container");
const getCategoriesHandler = container.resolve("GetCategoriesQueryHandler"); const getCategoriesHandler = container.resolve("GetCategoriesQueryHandler");
const getProductsHandler = container.resolve("GetProductsQueryHandler"); const getProductsHandler = container.resolve("GetProductsQueryHandler");
const getProductByIdHandler = container.resolve("GetProductByIdQueryHandler");
const createOrderHandler = container.resolve("CreateOrderCommandHandler"); const createOrderHandler = container.resolve("CreateOrderCommandHandler");
const createProductHandler = container.resolve("CreateProductCommandHandler"); const createProductHandler = container.resolve("CreateProductCommandHandler");
const setProductImageHandler = container.resolve("SetProductImageCommandHandler");
const getCategories = async (req, res, next) => { const getCategories = async (req, res, next) => {
try { try {
@@ -22,7 +27,19 @@ const getCategories = async (req, res, next) => {
const getProducts = async (req, res, next) => { const getProducts = async (req, res, next) => {
try { try {
const query = new GetProductsQuery(); const query = new GetProductsQuery();
const data = await getProductsHandler.handle(query); const products = await getProductsHandler.handle(query);
const data = products.map((product) => new ProductDTO(product, req));
return res.json({ data });
} catch (error) {
return next(error);
}
};
const getProductById = async (req, res, next) => {
try {
const query = new GetProductByIdQuery(req.params.productId);
const product = await getProductByIdHandler.handle(query);
const data = new ProductDTO(product, req);
return res.json({ data }); return res.json({ data });
} catch (error) { } catch (error) {
return next(error); return next(error);
@@ -31,8 +48,13 @@ const getProducts = async (req, res, next) => {
const createProduct = async (req, res, next) => { const createProduct = async (req, res, next) => {
try { try {
const command = new CreateProductCommand(req.body); const payload = {
const data = await createProductHandler.handle(command); ...req.body,
image_url: req.file ? `/images/uploads/${req.file.filename}` : req.body.image_url
};
const command = new CreateProductCommand(payload);
const product = await createProductHandler.handle(command);
const data = new ProductDTO(product, req);
return res.status(201).json({ data }); return res.status(201).json({ data });
} catch (error) { } catch (error) {
return next(error); return next(error);
@@ -49,9 +71,29 @@ const createOrder = async (req, res, next) => {
} }
}; };
const setProductImage = async (req, res, next) => {
try {
if (!req.file) {
const error = new Error("Image file is required");
error.statusCode = 400;
throw error;
}
const imageUrl = `/images/uploads/${req.file.filename}`;
const command = new SetProductImageCommand(req.params.productId, imageUrl);
const product = await setProductImageHandler.handle(command);
const data = new ProductDTO(product, req);
return res.json({ data });
} catch (error) {
return next(error);
}
};
module.exports = { module.exports = {
getCategories, getCategories,
getProducts, getProducts,
getProductById,
createProduct, createProduct,
createOrder createOrder,
setProductImage
}; };
@@ -3,6 +3,11 @@ const errorHandler = (err, req, res, next) => {
return next(err); return next(err);
} }
if (err.name === "MulterError") {
const message = err.code === "LIMIT_FILE_SIZE" ? "Image size must be <= 5MB" : err.message;
return res.status(400).json({ message });
}
const status = err.statusCode || 500; const status = err.statusCode || 500;
return res.status(status).json({ return res.status(status).json({
message: err.message || "Internal server error" message: err.message || "Internal server error"
@@ -0,0 +1,42 @@
const fs = require("fs");
const path = require("path");
const multer = require("multer");
const uploadDir = path.join(__dirname, "..", "..", "..", "images", "uploads");
fs.mkdirSync(uploadDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const safeBaseName = path
.parse(file.originalname)
.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 40) || "image";
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${safeBaseName}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
if (!file.mimetype || !file.mimetype.startsWith("image/")) {
const error = new Error("Only image files are allowed");
error.statusCode = 400;
return cb(error);
}
cb(null, true);
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024
}
});
module.exports = upload;
@@ -1,11 +1,14 @@
const express = require("express"); const express = require("express");
const controller = require("../Controller/shop.controller"); const controller = require("../Controller/shop.controller");
const upload = require("../Middleware/upload.middleware");
const router = express.Router(); const router = express.Router();
router.get("/categories", controller.getCategories); router.get("/categories", controller.getCategories);
router.get("/products", controller.getProducts); router.get("/products", controller.getProducts);
router.post("/products", controller.createProduct); router.get("/products/:productId", controller.getProductById);
router.post("/products", upload.single("image"), controller.createProduct);
router.post("/products/:productId/image", upload.single("image"), controller.setProductImage);
router.post("/orders", controller.createOrder); router.post("/orders", controller.createOrder);
module.exports = router; module.exports = router;
+2
View File
@@ -1,4 +1,5 @@
const express = require("express"); const express = require("express");
const path = require("path");
const helmet = require("helmet"); const helmet = require("helmet");
const cors = require("cors"); const cors = require("cors");
const morgan = require("morgan"); const morgan = require("morgan");
@@ -16,6 +17,7 @@ app.use(cors({ origin: true, credentials: true }));
app.use(morgan("dev")); app.use(morgan("dev"));
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use("/images", express.static(path.resolve(__dirname, "../../images")));
app.get("/api/health", (req, res) => { app.get("/api/health", (req, res) => {
res.json({ status: "ok", message: "CQRS backend running" }); res.json({ status: "ok", message: "CQRS backend running" });
@@ -0,0 +1,28 @@
class ProductDTO {
constructor(product, req) {
this.id = product.id;
this.category_id = product.categoryId;
this.category_name = product.category ? product.category.name : null;
this.name = product.name;
this.description = product.description;
this.price = Number(product.price);
this.image_url = ProductDTO.#buildImageUrl(product.imageUrl, req);
this.stock = product.stock;
this.created_at = product.createdAt;
}
static #buildImageUrl(imageUrl, req) {
if (!imageUrl) {
return null;
}
if (/^https?:\/\//i.test(imageUrl)) {
return imageUrl;
}
const host = req.get("host");
return `${req.protocol}://${host}${imageUrl}`;
}
}
module.exports = ProductDTO;
@@ -0,0 +1,8 @@
class SetProductImageCommand {
constructor(productId, imageUrl) {
this.product_id = productId;
this.image_url = imageUrl;
}
}
module.exports = SetProductImageCommand;
@@ -0,0 +1,11 @@
class SetProductImageCommandHandler {
constructor(shopRepository) {
this.shopRepository = shopRepository;
}
async handle(command) {
return this.shopRepository.setProductImage(command.product_id, command.image_url);
}
}
module.exports = SetProductImageCommandHandler;
@@ -0,0 +1,7 @@
class GetProductByIdQuery {
constructor(productId) {
this.product_id = productId;
}
}
module.exports = GetProductByIdQuery;
@@ -0,0 +1,11 @@
class GetProductByIdQueryHandler {
constructor(shopRepository) {
this.shopRepository = shopRepository;
}
async handle(query) {
return this.shopRepository.findProductById(query.product_id);
}
}
module.exports = GetProductByIdQueryHandler;
@@ -12,8 +12,10 @@ const GetCurrentUserQueryHandler = require("../../Application/User/Query/GetCurr
const GetCategoriesQueryHandler = require("../../Application/Shop/Query/GetCategoriesQueryHandler"); const GetCategoriesQueryHandler = require("../../Application/Shop/Query/GetCategoriesQueryHandler");
const GetProductsQueryHandler = require("../../Application/Shop/Query/GetProductsQueryHandler"); const GetProductsQueryHandler = require("../../Application/Shop/Query/GetProductsQueryHandler");
const GetProductByIdQueryHandler = require("../../Application/Shop/Query/GetProductByIdQueryHandler");
const CreateProductCommandHandler = require("../../Application/Shop/Command/CreateProductCommandHandler"); const CreateProductCommandHandler = require("../../Application/Shop/Command/CreateProductCommandHandler");
const CreateOrderCommandHandler = require("../../Application/Shop/Command/CreateOrderCommandHandler"); const CreateOrderCommandHandler = require("../../Application/Shop/Command/CreateOrderCommandHandler");
const SetProductImageCommandHandler = require("../../Application/Shop/Command/SetProductImageCommandHandler");
const container = new DIContainer(); const container = new DIContainer();
@@ -46,6 +48,10 @@ container.registerSingleton("GetProductsQueryHandler", (c) => {
return new GetProductsQueryHandler(c.resolve("ShopRepository")); return new GetProductsQueryHandler(c.resolve("ShopRepository"));
}); });
container.registerSingleton("GetProductByIdQueryHandler", (c) => {
return new GetProductByIdQueryHandler(c.resolve("ShopRepository"));
});
container.registerSingleton("CreateProductCommandHandler", (c) => { container.registerSingleton("CreateProductCommandHandler", (c) => {
return new CreateProductCommandHandler(c.resolve("ShopRepository")); return new CreateProductCommandHandler(c.resolve("ShopRepository"));
}); });
@@ -54,4 +60,8 @@ container.registerSingleton("CreateOrderCommandHandler", (c) => {
return new CreateOrderCommandHandler(c.resolve("ShopRepository")); return new CreateOrderCommandHandler(c.resolve("ShopRepository"));
}); });
container.registerSingleton("SetProductImageCommandHandler", (c) => {
return new SetProductImageCommandHandler(c.resolve("ShopRepository"));
});
module.exports = container; module.exports = container;
@@ -14,6 +14,30 @@ class ShopPrismaRepository {
}); });
} }
async findProductById(productId) {
const id = Number(productId);
if (Number.isNaN(id)) {
const error = new Error("product_id must be a number");
error.statusCode = 400;
throw error;
}
const product = await prisma.product.findUnique({
where: { id },
include: {
category: true
}
});
if (!product) {
const error = new Error("Product not found");
error.statusCode = 404;
throw error;
}
return product;
}
async createProduct(payload) { async createProduct(payload) {
const categoryId = Number(payload.category_id); const categoryId = Number(payload.category_id);
const stock = payload.stock == null ? 0 : Number(payload.stock); const stock = payload.stock == null ? 0 : Number(payload.stock);
@@ -121,6 +145,33 @@ class ShopPrismaRepository {
return order; return order;
}); });
} }
async setProductImage(productId, imageUrl) {
const id = Number(productId);
if (Number.isNaN(id) || !imageUrl) {
const error = new Error("product_id and image are required");
error.statusCode = 400;
throw error;
}
const product = await prisma.product.findUnique({ where: { id } });
if (!product) {
const error = new Error("Product not found");
error.statusCode = 404;
throw error;
}
return prisma.product.update({
where: { id },
data: {
imageUrl
},
include: {
category: true
}
});
}
} }
module.exports = ShopPrismaRepository; module.exports = ShopPrismaRepository;