For Frontend practice
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=postgresql://webstore_user:webstore_password@localhost:5432/webstore_db?schema=public
|
||||
JWT_SECRET=replace-with-a-long-random-secret
|
||||
JWT_EXPIRES_IN=7d
|
||||
COOKIE_NAME=webstore_token
|
||||
COOKIE_SECURE=false
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
npm-debug.log*
|
||||
public/images/
|
||||
!public/images/.gitkeep
|
||||
prisma/dev.db
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN apk add --no-cache openssl && npm install --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run prisma:generate
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,88 @@
|
||||
# Webstore Backend (Laxer + CQRS + Prisma)
|
||||
|
||||
Educational Node.js backend for frontend students.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
prisma/
|
||||
schema.prisma
|
||||
seed.js
|
||||
public/
|
||||
images/
|
||||
scripts/
|
||||
extract-images-from-build.bat
|
||||
src/
|
||||
Api/
|
||||
Controller/
|
||||
Router/
|
||||
Middleware/
|
||||
app.js
|
||||
server.js
|
||||
Application/
|
||||
User/
|
||||
Command/
|
||||
command.js
|
||||
commandhandler.js
|
||||
Querry/
|
||||
query.js
|
||||
queryhandler.js
|
||||
Services/
|
||||
DTO/
|
||||
Domain/
|
||||
IRepository/
|
||||
Models/
|
||||
Infrastructure/
|
||||
Repository/
|
||||
config/
|
||||
env.js
|
||||
prisma.js
|
||||
```
|
||||
|
||||
## Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This starts PostgreSQL and API, runs Prisma generate/db push/seed automatically.
|
||||
|
||||
## Auth (JWT in Cookie)
|
||||
|
||||
- `POST /api/users/register`
|
||||
- `POST /api/users/login`
|
||||
- `GET /api/users/me` (requires cookie)
|
||||
- `POST /api/users/logout` (requires cookie)
|
||||
|
||||
Cookie is HTTP-only and set with `sameSite=lax`.
|
||||
|
||||
## Shop endpoints
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/shop/categories`
|
||||
- `GET /api/shop/products`
|
||||
- `POST /api/shop/orders`
|
||||
|
||||
Order payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_name": "Jane Doe",
|
||||
"customer_email": "jane@example.com",
|
||||
"items": [
|
||||
{ "product_id": 1, "quantity": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Image extraction batch
|
||||
|
||||
```bat
|
||||
scripts\extract-images-from-build.bat "..\build" ".\public\images"
|
||||
```
|
||||
|
||||
Defaults (if arguments omitted):
|
||||
|
||||
- Source: `..\build`
|
||||
- Target: `.\public\images`
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: webstore_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: webstore_db
|
||||
POSTGRES_USER: webstore_user
|
||||
POSTGRES_PASSWORD: webstore_password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webstore_api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
PORT: 3000
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://webstore_user:webstore_password@db:5432/webstore_db?schema=public
|
||||
JWT_SECRET: dev-secret-change-me
|
||||
JWT_EXPIRES_IN: 7d
|
||||
COOKIE_NAME: webstore_token
|
||||
COOKIE_SECURE: "false"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
command: sh -c "npm run prisma:generate && npm run prisma:push && npm run prisma:seed && npm run dev"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copy and edit values for your production/student environment.
|
||||
|
||||
# API
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://webstore_user:webstore_password@db:5432/webstore_db?schema=public
|
||||
JWT_SECRET=change-me-in-real-use
|
||||
JWT_EXPIRES_IN=7d
|
||||
COOKIE_NAME=webstore_token
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# Database container bootstrap
|
||||
POSTGRES_DB=webstore_db
|
||||
POSTGRES_USER=webstore_user
|
||||
POSTGRES_PASSWORD=webstore_password
|
||||
|
||||
# Optional: host port overrides
|
||||
API_PORT=3000
|
||||
DB_PORT=5432
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: webstore_db_prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql/data
|
||||
|
||||
api:
|
||||
image: webstore-api:prod
|
||||
container_name: webstore_api_prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- db
|
||||
env_file:
|
||||
- .production.env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
PORT: ${PORT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN}
|
||||
COOKIE_NAME: ${COOKIE_NAME}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE}
|
||||
command: sh -c "npm run prisma:generate && npm run prisma:push && npm run prisma:seed && npm start"
|
||||
ports:
|
||||
- "${API_PORT:-3000}:3000"
|
||||
|
||||
volumes:
|
||||
postgres_data_prod:
|
||||
@@ -0,0 +1,69 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
set "ROOT_DIR=%~dp0"
|
||||
set "ENV_FILE=%ROOT_DIR%\.production.env"
|
||||
set "COMPOSE_FILE=%ROOT_DIR%\production.compose.yml"
|
||||
set "ARCHIVE_FILE=%ROOT_DIR%\images\webstore-production-images.tar"
|
||||
|
||||
set "API_IMAGE=webstore-api:prod"
|
||||
|
||||
echo [1/7] Checking Docker + Compose...
|
||||
docker --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Docker CLI is not available.
|
||||
exit /b 1
|
||||
)
|
||||
docker compose version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Docker Compose plugin is not available.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/7] Validating required files...
|
||||
if not exist "%ENV_FILE%" (
|
||||
echo ERROR: Missing env file: "%ENV_FILE%"
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%COMPOSE_FILE%" (
|
||||
echo ERROR: Missing compose file: "%COMPOSE_FILE%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/7] Loading prebuilt image archive if present...
|
||||
if exist "%ARCHIVE_FILE%" (
|
||||
docker load -i "%ARCHIVE_FILE%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to load image archive.
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo WARN: "%ARCHIVE_FILE%" not found. Assuming images already exist locally.
|
||||
)
|
||||
|
||||
echo [4/7] Ensuring API image exists locally...
|
||||
docker image inspect "%API_IMAGE%" >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: API image "%API_IMAGE%" not found.
|
||||
echo Run scripts\build-production-images.bat first or provide the archive in images\.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [5/7] Starting production stack for students...
|
||||
pushd "%ROOT_DIR%" >nul
|
||||
docker compose --env-file .production.env -f production.compose.yml up -d
|
||||
if errorlevel 1 (
|
||||
popd >nul
|
||||
echo ERROR: Failed to start compose stack.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [6/7] Showing running services...
|
||||
docker compose --env-file .production.env -f production.compose.yml ps
|
||||
|
||||
echo [7/7] Done.
|
||||
echo API should be available at http://localhost:3000 (or API_PORT in .production.env)
|
||||
echo Stop stack with:
|
||||
echo docker compose --env-file .production.env -f production.compose.yml down
|
||||
popd >nul
|
||||
exit /b 0
|
||||
Binary file not shown.
Generated
+1534
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "webstore-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Teaching backend for a webstore using Node.js, Express, and PostgreSQL",
|
||||
"main": "src/Api/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/Api/server.js",
|
||||
"start": "node src/Api/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:seed": "node prisma/seed.js",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
"postgres",
|
||||
"webstore",
|
||||
"backend"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"@prisma/client": "^5.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4",
|
||||
"prisma": "^5.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
name String @unique
|
||||
description String?
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
imageUrl String?
|
||||
stock Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
orderItems OrderItem[]
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
customerName String
|
||||
customerEmail String
|
||||
totalPrice Decimal @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now())
|
||||
items OrderItem[]
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId Int
|
||||
productId Int
|
||||
quantity Int
|
||||
unitPrice Decimal @db.Decimal(10, 2)
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const categories = [
|
||||
{ name: "Shoes", slug: "shoes" },
|
||||
{ name: "Bags", slug: "bags" },
|
||||
{ name: "Accessories", slug: "accessories" }
|
||||
];
|
||||
|
||||
for (const category of categories) {
|
||||
await prisma.category.upsert({
|
||||
where: { slug: category.slug },
|
||||
update: {},
|
||||
create: category
|
||||
});
|
||||
}
|
||||
|
||||
const shoes = await prisma.category.findUnique({ where: { slug: "shoes" } });
|
||||
const bags = await prisma.category.findUnique({ where: { slug: "bags" } });
|
||||
const accessories = await prisma.category.findUnique({ where: { slug: "accessories" } });
|
||||
|
||||
const products = [
|
||||
{
|
||||
categoryId: shoes.id,
|
||||
name: "Street Runner",
|
||||
description: "Lightweight city sneaker.",
|
||||
price: "18990.00",
|
||||
imageUrl: "/images/street-runner.jpg",
|
||||
stock: 24
|
||||
},
|
||||
{
|
||||
categoryId: shoes.id,
|
||||
name: "Trail Edge",
|
||||
description: "Stable shoe for outdoor tracks.",
|
||||
price: "24990.00",
|
||||
imageUrl: "/images/trail-edge.jpg",
|
||||
stock: 13
|
||||
},
|
||||
{
|
||||
categoryId: bags.id,
|
||||
name: "Urban Tote",
|
||||
description: "Everyday tote with zipper top.",
|
||||
price: "14990.00",
|
||||
imageUrl: "/images/urban-tote.jpg",
|
||||
stock: 30
|
||||
},
|
||||
{
|
||||
categoryId: accessories.id,
|
||||
name: "Classic Cap",
|
||||
description: "Adjustable cotton cap.",
|
||||
price: "6990.00",
|
||||
imageUrl: "/images/classic-cap.jpg",
|
||||
stock: 42
|
||||
}
|
||||
];
|
||||
|
||||
for (const product of products) {
|
||||
await prisma.product.upsert({
|
||||
where: { name: product.name },
|
||||
update: {},
|
||||
create: product
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
console.log("Prisma seed completed.");
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
set "ROOT_DIR=%~dp0.."
|
||||
set "IMAGE_OUT_DIR=%ROOT_DIR%\images"
|
||||
set "ARCHIVE_FILE=%IMAGE_OUT_DIR%\webstore-production-images.tar"
|
||||
|
||||
set "API_IMAGE=webstore-api:prod"
|
||||
set "DB_IMAGE=postgres:16-alpine"
|
||||
|
||||
echo [1/6] Checking Docker availability...
|
||||
docker --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Docker CLI is not available. Install Docker Desktop first.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/6] Building production API image: %API_IMAGE%
|
||||
pushd "%ROOT_DIR%" >nul
|
||||
docker build -t "%API_IMAGE%" -f Dockerfile .
|
||||
if errorlevel 1 (
|
||||
popd >nul
|
||||
echo ERROR: Failed to build API image.
|
||||
exit /b 1
|
||||
)
|
||||
popd >nul
|
||||
|
||||
echo [3/6] Pulling database image: %DB_IMAGE%
|
||||
docker pull "%DB_IMAGE%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to pull database image.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [4/6] Preparing image output directory: %IMAGE_OUT_DIR%
|
||||
if not exist "%IMAGE_OUT_DIR%" mkdir "%IMAGE_OUT_DIR%"
|
||||
|
||||
echo [5/6] Exporting images to archive...
|
||||
if exist "%ARCHIVE_FILE%" del /f /q "%ARCHIVE_FILE%"
|
||||
docker save -o "%ARCHIVE_FILE%" "%API_IMAGE%" "%DB_IMAGE%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Failed to export Docker images.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [6/6] Done.
|
||||
echo Created archive: "%ARCHIVE_FILE%"
|
||||
echo Share this file with students together with production.compose.yml and .production.env.
|
||||
exit /b 0
|
||||
@@ -0,0 +1,57 @@
|
||||
const GetCategoriesQuery = require("../../Application/Shop/Query/GetCategoriesQuery");
|
||||
const GetProductsQuery = require("../../Application/Shop/Query/GetProductsQuery");
|
||||
const CreateOrderCommand = require("../../Application/Shop/Command/CreateOrderCommand");
|
||||
const CreateProductCommand = require("../../Application/Shop/Command/CreateProductCommand");
|
||||
const container = require("../../Infrastructure/DI/container");
|
||||
|
||||
const getCategoriesHandler = container.resolve("GetCategoriesQueryHandler");
|
||||
const getProductsHandler = container.resolve("GetProductsQueryHandler");
|
||||
const createOrderHandler = container.resolve("CreateOrderCommandHandler");
|
||||
const createProductHandler = container.resolve("CreateProductCommandHandler");
|
||||
|
||||
const getCategories = async (req, res, next) => {
|
||||
try {
|
||||
const query = new GetCategoriesQuery();
|
||||
const data = await getCategoriesHandler.handle(query);
|
||||
return res.json({ data });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getProducts = async (req, res, next) => {
|
||||
try {
|
||||
const query = new GetProductsQuery();
|
||||
const data = await getProductsHandler.handle(query);
|
||||
return res.json({ data });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createProduct = async (req, res, next) => {
|
||||
try {
|
||||
const command = new CreateProductCommand(req.body);
|
||||
const data = await createProductHandler.handle(command);
|
||||
return res.status(201).json({ data });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createOrder = async (req, res, next) => {
|
||||
try {
|
||||
const command = new CreateOrderCommand(req.body);
|
||||
const data = await createOrderHandler.handle(command);
|
||||
return res.status(201).json({ data });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getCategories,
|
||||
getProducts,
|
||||
createProduct,
|
||||
createOrder
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
const RegisterUserCommand = require("../../Application/User/Command/RegisterUserCommand");
|
||||
const LoginUserCommand = require("../../Application/User/Command/LoginUserCommand");
|
||||
const LogoutUserCommand = require("../../Application/User/Command/LogoutUserCommand");
|
||||
const GetCurrentUserQuery = require("../../Application/User/Query/GetCurrentUserQuery");
|
||||
const container = require("../../Infrastructure/DI/container");
|
||||
|
||||
const authService = container.resolve("AuthService");
|
||||
const registerHandler = container.resolve("RegisterUserCommandHandler");
|
||||
const loginHandler = container.resolve("LoginUserCommandHandler");
|
||||
const logoutHandler = container.resolve("LogoutUserCommandHandler");
|
||||
const currentUserQueryHandler = container.resolve("GetCurrentUserQueryHandler");
|
||||
|
||||
const register = async (req, res, next) => {
|
||||
try {
|
||||
const command = new RegisterUserCommand(req.body);
|
||||
const result = await registerHandler.handle(command);
|
||||
|
||||
res.cookie(authService.cookieName, result.token, authService.cookieOptions());
|
||||
return res.status(201).json({ data: result.user });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (req, res, next) => {
|
||||
try {
|
||||
const command = new LoginUserCommand(req.body);
|
||||
const result = await loginHandler.handle(command);
|
||||
|
||||
res.cookie(authService.cookieName, result.token, authService.cookieOptions());
|
||||
return res.json({ data: result.user });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const me = async (req, res, next) => {
|
||||
try {
|
||||
const query = new GetCurrentUserQuery({ userId: req.auth.userId });
|
||||
const result = await currentUserQueryHandler.handle(query);
|
||||
return res.json({ data: result });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async (req, res, next) => {
|
||||
try {
|
||||
const command = new LogoutUserCommand({ userId: req.auth.userId });
|
||||
await logoutHandler.handle(command);
|
||||
|
||||
res.clearCookie(authService.cookieName, authService.clearCookieOptions());
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
me,
|
||||
logout
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
const AuthService = require("../../Application/Services/AuthService");
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
try {
|
||||
const token = req.cookies[authService.cookieName];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: "Authentication required." });
|
||||
}
|
||||
|
||||
req.auth = authService.verifyToken(token);
|
||||
return next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ message: "Invalid or expired token." });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = authMiddleware;
|
||||
@@ -0,0 +1,12 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
const status = err.statusCode || 500;
|
||||
return res.status(status).json({
|
||||
message: err.message || "Internal server error"
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
@@ -0,0 +1,5 @@
|
||||
const notFound = (req, res) => {
|
||||
res.status(404).json({ message: "Route not found" });
|
||||
};
|
||||
|
||||
module.exports = notFound;
|
||||
@@ -0,0 +1,11 @@
|
||||
const express = require("express");
|
||||
const controller = require("../Controller/shop.controller");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/categories", controller.getCategories);
|
||||
router.get("/products", controller.getProducts);
|
||||
router.post("/products", controller.createProduct);
|
||||
router.post("/orders", controller.createOrder);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require("express");
|
||||
const controller = require("../Controller/user.controller");
|
||||
const authMiddleware = require("../Middleware/auth.middleware");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/register", controller.register);
|
||||
router.post("/login", controller.login);
|
||||
router.get("/me", authMiddleware, controller.me);
|
||||
router.post("/logout", authMiddleware, controller.logout);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,30 @@
|
||||
const express = require("express");
|
||||
const helmet = require("helmet");
|
||||
const cors = require("cors");
|
||||
const morgan = require("morgan");
|
||||
const cookieParser = require("cookie-parser");
|
||||
|
||||
const userRouter = require("./Router/user.router");
|
||||
const shopRouter = require("./Router/shop.router");
|
||||
const notFound = require("./Middleware/notFound");
|
||||
const errorHandler = require("./Middleware/errorHandler");
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(morgan("dev"));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok", message: "CQRS backend running" });
|
||||
});
|
||||
|
||||
app.use("/api/users", userRouter);
|
||||
app.use("/api/shop", shopRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,6 @@
|
||||
const app = require("./app");
|
||||
const env = require("../config/env");
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`Server listening on port ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
class UserDTO {
|
||||
constructor(user) {
|
||||
this.id = user.id;
|
||||
this.name = user.name;
|
||||
this.email = user.email;
|
||||
this.createdAt = user.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserDTO;
|
||||
@@ -0,0 +1,37 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const env = require("../../config/env");
|
||||
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.cookieName = env.cookieName;
|
||||
}
|
||||
|
||||
signToken(payload) {
|
||||
return jwt.sign(payload, env.jwtSecret, { expiresIn: env.jwtExpiresIn });
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
return jwt.verify(token, env.jwtSecret);
|
||||
}
|
||||
|
||||
cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.cookieSecure,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
}
|
||||
|
||||
clearCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.cookieSecure,
|
||||
path: "/"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthService;
|
||||
@@ -0,0 +1,13 @@
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
class PasswordService {
|
||||
async hash(rawPassword) {
|
||||
return bcrypt.hash(rawPassword, 10);
|
||||
}
|
||||
|
||||
async compare(rawPassword, hash) {
|
||||
return bcrypt.compare(rawPassword, hash);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PasswordService;
|
||||
@@ -0,0 +1,9 @@
|
||||
class CreateOrderCommand {
|
||||
constructor(payload) {
|
||||
this.customer_name = payload.customer_name;
|
||||
this.customer_email = payload.customer_email;
|
||||
this.items = payload.items;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CreateOrderCommand;
|
||||
@@ -0,0 +1,11 @@
|
||||
class CreateOrderCommandHandler {
|
||||
constructor(shopRepository) {
|
||||
this.shopRepository = shopRepository;
|
||||
}
|
||||
|
||||
async handle(command) {
|
||||
return this.shopRepository.createOrder(command);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CreateOrderCommandHandler;
|
||||
@@ -0,0 +1,12 @@
|
||||
class CreateProductCommand {
|
||||
constructor(payload) {
|
||||
this.category_id = payload.category_id;
|
||||
this.name = payload.name;
|
||||
this.description = payload.description;
|
||||
this.price = payload.price;
|
||||
this.image_url = payload.image_url;
|
||||
this.stock = payload.stock;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CreateProductCommand;
|
||||
@@ -0,0 +1,11 @@
|
||||
class CreateProductCommandHandler {
|
||||
constructor(shopRepository) {
|
||||
this.shopRepository = shopRepository;
|
||||
}
|
||||
|
||||
async handle(command) {
|
||||
return this.shopRepository.createProduct(command);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CreateProductCommandHandler;
|
||||
@@ -0,0 +1,3 @@
|
||||
class GetCategoriesQuery {}
|
||||
|
||||
module.exports = GetCategoriesQuery;
|
||||
@@ -0,0 +1,11 @@
|
||||
class GetCategoriesQueryHandler {
|
||||
constructor(shopRepository) {
|
||||
this.shopRepository = shopRepository;
|
||||
}
|
||||
|
||||
async handle() {
|
||||
return this.shopRepository.findCategories();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GetCategoriesQueryHandler;
|
||||
@@ -0,0 +1,3 @@
|
||||
class GetProductsQuery {}
|
||||
|
||||
module.exports = GetProductsQuery;
|
||||
@@ -0,0 +1,11 @@
|
||||
class GetProductsQueryHandler {
|
||||
constructor(shopRepository) {
|
||||
this.shopRepository = shopRepository;
|
||||
}
|
||||
|
||||
async handle() {
|
||||
return this.shopRepository.findProducts();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GetProductsQueryHandler;
|
||||
@@ -0,0 +1,8 @@
|
||||
class LoginUserCommand {
|
||||
constructor(payload) {
|
||||
this.email = payload.email;
|
||||
this.password = payload.password;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LoginUserCommand;
|
||||
@@ -0,0 +1,37 @@
|
||||
const PasswordService = require("../../Services/PasswordService");
|
||||
const UserDTO = require("../../DTO/UserDTO");
|
||||
|
||||
class LoginUserCommandHandler {
|
||||
constructor(userRepository, authService) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = authService;
|
||||
this.passwordService = new PasswordService();
|
||||
}
|
||||
|
||||
async handle(command) {
|
||||
if (!command.email || !command.password) {
|
||||
const error = new Error("email and password are required");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByEmail(command.email);
|
||||
if (!user) {
|
||||
const error = new Error("Invalid credentials");
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isValid = await this.passwordService.compare(command.password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
const error = new Error("Invalid credentials");
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const token = this.authService.signToken({ userId: user.id, email: user.email });
|
||||
return { user: new UserDTO(user), token };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LoginUserCommandHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
class LogoutUserCommand {
|
||||
constructor(payload) {
|
||||
this.userId = payload.userId;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LogoutUserCommand;
|
||||
@@ -0,0 +1,11 @@
|
||||
class LogoutUserCommandHandler {
|
||||
async handle(command) {
|
||||
if (!command.userId) {
|
||||
const error = new Error("Unauthorized");
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LogoutUserCommandHandler;
|
||||
@@ -0,0 +1,9 @@
|
||||
class RegisterUserCommand {
|
||||
constructor(payload) {
|
||||
this.name = payload.name;
|
||||
this.email = payload.email;
|
||||
this.password = payload.password;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RegisterUserCommand;
|
||||
@@ -0,0 +1,37 @@
|
||||
const PasswordService = require("../../Services/PasswordService");
|
||||
const UserDTO = require("../../DTO/UserDTO");
|
||||
|
||||
class RegisterUserCommandHandler {
|
||||
constructor(userRepository, authService) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = authService;
|
||||
this.passwordService = new PasswordService();
|
||||
}
|
||||
|
||||
async handle(command) {
|
||||
if (!command.name || !command.email || !command.password) {
|
||||
const error = new Error("name, email and password are required");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existing = await this.userRepository.findByEmail(command.email);
|
||||
if (existing) {
|
||||
const error = new Error("Email already in use");
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const passwordHash = await this.passwordService.hash(command.password);
|
||||
const user = await this.userRepository.create({
|
||||
name: command.name,
|
||||
email: command.email,
|
||||
passwordHash
|
||||
});
|
||||
|
||||
const token = this.authService.signToken({ userId: user.id, email: user.email });
|
||||
return { user: new UserDTO(user), token };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RegisterUserCommandHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
class GetCurrentUserQuery {
|
||||
constructor(payload) {
|
||||
this.userId = payload.userId;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GetCurrentUserQuery;
|
||||
@@ -0,0 +1,26 @@
|
||||
const UserDTO = require("../../DTO/UserDTO");
|
||||
|
||||
class GetCurrentUserQueryHandler {
|
||||
constructor(userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
async handle(query) {
|
||||
if (!query.userId) {
|
||||
const error = new Error("Unauthorized");
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findById(query.userId);
|
||||
if (!user) {
|
||||
const error = new Error("User not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new UserDTO(user);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GetCurrentUserQueryHandler;
|
||||
@@ -0,0 +1,15 @@
|
||||
class IUserRepository {
|
||||
async findById(_id) {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
|
||||
async findByEmail(_email) {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
|
||||
async create(_data) {
|
||||
throw new Error("Method not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IUserRepository;
|
||||
@@ -0,0 +1,11 @@
|
||||
class User {
|
||||
constructor({ id, name, email, passwordHash, createdAt }) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.passwordHash = passwordHash;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User;
|
||||
@@ -0,0 +1,32 @@
|
||||
class DIContainer {
|
||||
constructor() {
|
||||
this.registry = new Map();
|
||||
this.singletons = new Map();
|
||||
}
|
||||
|
||||
registerSingleton(token, factory) {
|
||||
this.registry.set(token, { factory, lifetime: "singleton" });
|
||||
}
|
||||
|
||||
registerTransient(token, factory) {
|
||||
this.registry.set(token, { factory, lifetime: "transient" });
|
||||
}
|
||||
|
||||
resolve(token) {
|
||||
const entry = this.registry.get(token);
|
||||
if (!entry) {
|
||||
throw new Error(`Dependency not registered: ${token}`);
|
||||
}
|
||||
|
||||
if (entry.lifetime === "singleton") {
|
||||
if (!this.singletons.has(token)) {
|
||||
this.singletons.set(token, entry.factory(this));
|
||||
}
|
||||
return this.singletons.get(token);
|
||||
}
|
||||
|
||||
return entry.factory(this);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DIContainer;
|
||||
@@ -0,0 +1,57 @@
|
||||
const DIContainer = require("./DIContainer");
|
||||
|
||||
const AuthService = require("../../Application/Services/AuthService");
|
||||
|
||||
const UserPrismaRepository = require("../Repository/UserPrismaRepository");
|
||||
const ShopPrismaRepository = require("../Repository/ShopPrismaRepository");
|
||||
|
||||
const RegisterUserCommandHandler = require("../../Application/User/Command/RegisterUserCommandHandler");
|
||||
const LoginUserCommandHandler = require("../../Application/User/Command/LoginUserCommandHandler");
|
||||
const LogoutUserCommandHandler = require("../../Application/User/Command/LogoutUserCommandHandler");
|
||||
const GetCurrentUserQueryHandler = require("../../Application/User/Query/GetCurrentUserQueryHandler");
|
||||
|
||||
const GetCategoriesQueryHandler = require("../../Application/Shop/Query/GetCategoriesQueryHandler");
|
||||
const GetProductsQueryHandler = require("../../Application/Shop/Query/GetProductsQueryHandler");
|
||||
const CreateProductCommandHandler = require("../../Application/Shop/Command/CreateProductCommandHandler");
|
||||
const CreateOrderCommandHandler = require("../../Application/Shop/Command/CreateOrderCommandHandler");
|
||||
|
||||
const container = new DIContainer();
|
||||
|
||||
container.registerSingleton("AuthService", () => new AuthService());
|
||||
|
||||
container.registerSingleton("UserRepository", () => new UserPrismaRepository());
|
||||
container.registerSingleton("ShopRepository", () => new ShopPrismaRepository());
|
||||
|
||||
container.registerSingleton("RegisterUserCommandHandler", (c) => {
|
||||
return new RegisterUserCommandHandler(c.resolve("UserRepository"), c.resolve("AuthService"));
|
||||
});
|
||||
|
||||
container.registerSingleton("LoginUserCommandHandler", (c) => {
|
||||
return new LoginUserCommandHandler(c.resolve("UserRepository"), c.resolve("AuthService"));
|
||||
});
|
||||
|
||||
container.registerSingleton("LogoutUserCommandHandler", () => {
|
||||
return new LogoutUserCommandHandler();
|
||||
});
|
||||
|
||||
container.registerSingleton("GetCurrentUserQueryHandler", (c) => {
|
||||
return new GetCurrentUserQueryHandler(c.resolve("UserRepository"));
|
||||
});
|
||||
|
||||
container.registerSingleton("GetCategoriesQueryHandler", (c) => {
|
||||
return new GetCategoriesQueryHandler(c.resolve("ShopRepository"));
|
||||
});
|
||||
|
||||
container.registerSingleton("GetProductsQueryHandler", (c) => {
|
||||
return new GetProductsQueryHandler(c.resolve("ShopRepository"));
|
||||
});
|
||||
|
||||
container.registerSingleton("CreateProductCommandHandler", (c) => {
|
||||
return new CreateProductCommandHandler(c.resolve("ShopRepository"));
|
||||
});
|
||||
|
||||
container.registerSingleton("CreateOrderCommandHandler", (c) => {
|
||||
return new CreateOrderCommandHandler(c.resolve("ShopRepository"));
|
||||
});
|
||||
|
||||
module.exports = container;
|
||||
@@ -0,0 +1,126 @@
|
||||
const prisma = require("../../config/prisma");
|
||||
|
||||
class ShopPrismaRepository {
|
||||
async findCategories() {
|
||||
return prisma.category.findMany({ orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
async findProducts() {
|
||||
return prisma.product.findMany({
|
||||
include: {
|
||||
category: true
|
||||
},
|
||||
orderBy: { id: "asc" }
|
||||
});
|
||||
}
|
||||
|
||||
async createProduct(payload) {
|
||||
const categoryId = Number(payload.category_id);
|
||||
const stock = payload.stock == null ? 0 : Number(payload.stock);
|
||||
const price = Number(payload.price);
|
||||
|
||||
if (Number.isNaN(categoryId) || !payload.name || Number.isNaN(price) || Number.isNaN(stock)) {
|
||||
const error = new Error("category_id, name, price and stock are required");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (price < 0 || stock < 0) {
|
||||
const error = new Error("price and stock must be >= 0");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const category = await prisma.category.findUnique({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
const error = new Error("Category not found");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return prisma.product.create({
|
||||
data: {
|
||||
categoryId,
|
||||
name: payload.name,
|
||||
description: payload.description || null,
|
||||
price,
|
||||
imageUrl: payload.image_url || null,
|
||||
stock
|
||||
},
|
||||
include: {
|
||||
category: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createOrder(payload) {
|
||||
const { customer_name: customerName, customer_email: customerEmail, items } = payload;
|
||||
|
||||
if (!customerName || !customerEmail || !Array.isArray(items) || items.length === 0) {
|
||||
const error = new Error("customer_name, customer_email and items are required");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
let totalPrice = 0;
|
||||
const orderItems = [];
|
||||
|
||||
for (const item of items) {
|
||||
const productId = Number(item.product_id);
|
||||
const quantity = Number(item.quantity);
|
||||
|
||||
if (Number.isNaN(productId) || Number.isNaN(quantity) || quantity <= 0) {
|
||||
const error = new Error("Each item needs product_id and quantity > 0");
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const product = await tx.product.findUnique({ where: { id: productId } });
|
||||
if (!product) {
|
||||
const error = new Error(`Product ${productId} not found`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (product.stock < quantity) {
|
||||
const error = new Error(`Not enough stock for product ${product.name}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
totalPrice += Number(product.price) * quantity;
|
||||
orderItems.push({ product, quantity });
|
||||
}
|
||||
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
customerName,
|
||||
customerEmail,
|
||||
totalPrice,
|
||||
items: {
|
||||
create: orderItems.map((entry) => ({
|
||||
productId: entry.product.id,
|
||||
quantity: entry.quantity,
|
||||
unitPrice: entry.product.price
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
items: true
|
||||
}
|
||||
});
|
||||
|
||||
for (const entry of orderItems) {
|
||||
await tx.product.update({
|
||||
where: { id: entry.product.id },
|
||||
data: { stock: entry.product.stock - entry.quantity }
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShopPrismaRepository;
|
||||
@@ -0,0 +1,29 @@
|
||||
const prisma = require("../../config/prisma");
|
||||
const IUserRepository = require("../../Domain/IRepository/IUserRepository");
|
||||
const User = require("../../Domain/Models/User");
|
||||
|
||||
class UserPrismaRepository extends IUserRepository {
|
||||
async findById(id) {
|
||||
const user = await prisma.user.findUnique({ where: { id: Number(id) } });
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
async findByEmail(email) {
|
||||
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
async create(data) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email.toLowerCase(),
|
||||
passwordHash: data.passwordHash
|
||||
}
|
||||
});
|
||||
|
||||
return new User(user);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserPrismaRepository;
|
||||
@@ -0,0 +1,14 @@
|
||||
const path = require("path");
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
module.exports = {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
nodeEnv: process.env.NODE_ENV || "development",
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
jwtSecret: process.env.JWT_SECRET || "dev-secret",
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || "7d",
|
||||
cookieName: process.env.COOKIE_NAME || "webstore_token",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true"
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
Reference in New Issue
Block a user