This commit is contained in:
LBerardi 2025-10-31 11:08:46 +01:00
commit fefed588b4
34 changed files with 10313 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
node_modules/
dist/
*.log
.env
data/*.db
data/*.db-journal
backend/node_modules
backend/dist
frontend/node_modules
frontend/dist
frontend/dist-ssr
.DS_Store

157
README.md Normal file
View File

@ -0,0 +1,157 @@
# Order Management System (OMS)
Ein modernes Order Management System für konfigurierbare Produkte, entwickelt als White-Label-Lösung für On-Premise-Hosting.
## Übersicht
Dieses System wurde für eine Coding Challenge entwickelt und zeigt:
- RESTful API mit Express.js und TypeScript
- Eingabevalidierung mit Zod
- SQLite3 Datenbank mit automatischer Initialisierung
- Event-Driven Architecture (simuliert für Demo)
- React Frontend mit Material UI
- Docker-Container für einfaches Deployment
- Unit-Tests mit Jest
## Architektur
Siehe:
- [ARCHITEKTUR.md](./ARCHITEKTUR.md) - Technische Übersicht mit Diagrammen
- [docs/aufgabe1-architektur.md](./docs/aufgabe1-architektur.md) - Ausführliche Antwort zu Aufgabe 1 mit Begründungen
## Schnellstart
### Voraussetzungen
- Node.js 18+
- npm
- Docker (optional)
### Installation
```bash
# Alle Dependencies installieren
npm run install:all
# Datenbank-Ordner wird automatisch erstellt beim ersten Start
```
### Entwicklung
```bash
# Terminal 1: Backend
npm run dev
# Terminal 2: Frontend
npm run dev:frontend
```
- Backend: http://localhost:3990
- Frontend: http://localhost:3980
### Mit Docker
```bash
docker compose up
```
### Tests
```bash
npm test
```
## API Dokumentation
### GET /orders
Ruft alle Bestellungen ab.
**Response (200):**
```json
{
"success": true,
"data": [...],
"count": 5
}
```
### POST /orders
Erstellt eine neue Bestellung.
**Request:**
```json
{
"customerId": "CUST1",
"customerName": "Lui Denkwerk",
"customerEmail": "lui@test.com",
"items": [
{
"productId": "TISCH1",
"quantity": 1,
"price": 29.99
}
],
"shippingAddress": {
"street": "yxcstraße 1",
"city": "Grevenbroich",
"postalCode": "41515",
"country": "DE"
}
}
```
**Response (201):**
```json
{
"success": true,
"data": {
"id": 1,
"orderNumber": "Order-1234567890-123",
"totalAmount": 299.99,
"status": "pending",
...
}
}
```
## Features
### Backend
- Express.js mit TypeScript
- Zod für Runtime-Validierung
- SQLite3 (zero-config, ideal für On-Premise)
- Event-System (simuliert, in Production würde ich RabbitMQ oder Kafka nutzen)
- Jest Tests
### Frontend
- React 18 mit TypeScript
- Material UI 5 für schnelle UI-Entwicklung
- Vite als Build-Tool
- Client-Side Validierung
- Error-Handling
- Zwei Views: Bestellung erstellen + Bestellübersicht
### Deployment
- Docker & Docker Compose
- Nginx als Reverse Proxy für Frontend
- Isolierte Container für Backend/Frontend
## Technische Entscheidungen
**SQLite statt PostgreSQL**: Für die Challenge ausreichend, leicht zu deployen, keine externe DB nötig. In Production würde ich auf PostgreSQL wechseln.
**Event-Simulation**: Events werden mit Console-Logs simuliert.
**Keine Auth**: Nicht Teil der Challenge. In Production JWT oder OAuth implementieren.
**Material UI**: Schnelle UI-Entwicklung mit professionellem Look ohne viel CSS schreiben zu müssen.
---

14
backend/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3990
CMD ["npm", "start"]

0
backend/data/.gitkeep Normal file
View File

11
backend/jest.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.test.ts',
],
};

6845
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "oms-backend",
"version": "1.0.0",
"description": "OMS Backend",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [],
"author": "LBerardi",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"sqlite3": "^5.1.7",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.7",
"@types/jest": "^29.5.11",
"@types/sqlite3": "^3.1.11",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"supertest": "^6.3.3",
"@types/supertest": "^6.0.2"
}
}

133
backend/src/database.ts Normal file
View File

@ -0,0 +1,133 @@
import sqlite3 from 'sqlite3';
import { Order } from './types';
import * as fs from 'fs';
import * as path from 'path';
const DB_DIR = './data';
const DB_PATH = path.join(DB_DIR, 'orders.db');
// Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(DB_DIR)) {
fs.mkdirSync(DB_DIR, { recursive: true });
}
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('DB Fehler:', err);
}
});
export function initDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
db.run(`
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
orderNumber TEXT UNIQUE NOT NULL,
customerId TEXT NOT NULL,
customerName TEXT NOT NULL,
customerEmail TEXT NOT NULL,
items TEXT NOT NULL,
shippingAddress TEXT NOT NULL,
totalAmount REAL NOT NULL,
status TEXT DEFAULT 'pending',
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
export function createOrder(order: Order): Promise<Order> {
return new Promise((resolve, reject) => {
const query = `
INSERT INTO orders (orderNumber, customerId, customerName, customerEmail, items, shippingAddress, totalAmount, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
// Bestellnummer generieren
const orderNumber = `Order-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
db.run(
query,
[
orderNumber,
order.customerId,
order.customerName,
order.customerEmail,
JSON.stringify(order.items),
JSON.stringify(order.shippingAddress),
order.totalAmount,
'pending'
],
function(err) {
if (err) {
reject(err);
} else {
resolve({
...order,
id: this.lastID,
orderNumber: orderNumber,
status: 'pending',
createdAt: new Date().toISOString()
});
}
}
);
});
}
export function getOrder(id: number): Promise<Order | null> {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM orders WHERE id = ?', [id], (err, row: any) => {
if (err) {
reject(err);
} else if (!row) {
resolve(null);
} else {
resolve({
id: row.id,
orderNumber: row.orderNumber,
customerId: row.customerId,
customerName: row.customerName,
customerEmail: row.customerEmail,
items: JSON.parse(row.items),
shippingAddress: JSON.parse(row.shippingAddress),
totalAmount: row.totalAmount,
status: row.status,
createdAt: row.createdAt
});
}
});
});
}
export function getAllOrders(): Promise<Order[]> {
return new Promise((resolve, reject) => {
db.all('SELECT * FROM orders ORDER BY createdAt DESC', [], (err, rows: any[]) => {
if (err) {
reject(err);
} else {
const orders = rows.map(row => ({
id: row.id,
orderNumber: row.orderNumber,
customerId: row.customerId,
customerName: row.customerName,
customerEmail: row.customerEmail,
items: JSON.parse(row.items),
shippingAddress: JSON.parse(row.shippingAddress),
totalAmount: row.totalAmount,
status: row.status,
createdAt: row.createdAt
}));
resolve(orders);
}
});
});
}
export { db };

105
backend/src/server.ts Normal file
View File

@ -0,0 +1,105 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import path from 'path';
import { orderSchema } from './validation';
import { createOrder } from './database';
import { emitOrderCreated } from './simulate/events';
import { initDatabase } from './database';
const app = express();
const PORT = process.env.PORT || 3990;
app.use(cors());
app.use(express.json());
// GET /orders - Alle Bestellungen abrufen
app.get('/orders', async (req: Request, res: Response) => {
try {
const { getAllOrders } = require('./database');
const orders = await getAllOrders();
res.status(200).json({
success: true,
data: orders,
count: orders.length
});
} catch (error: any) {
console.error('Fehler beim Abrufen der Bestellungen:', error);
res.status(500).json({
success: false,
error: 'Interner Serverfehler'
});
}
});
// POST /orders - Erstellt eine neue Bestellung
app.post('/orders', async (req: Request, res: Response) => {
try {
const validatedData = orderSchema.parse(req.body);
// Gesamtbetrag berechnen
const totalAmount = validatedData.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
const order = await createOrder({
...validatedData,
totalAmount,
status: 'pending'
});
// Events auslösen
emitOrderCreated(order);
res.status(201).json({
success: true,
data: order,
message: 'Bestellung erfolgreich erstellt'
});
} catch (error: any) {
if (error.name === 'ZodError') {
return res.status(400).json({
success: false,
error: 'Validierungsfehler',
details: error.errors
});
}
console.error('Fehler beim Erstellen der Bestellung:', error);
res.status(500).json({
success: false,
error: 'Interner Serverfehler'
});
}
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({
success: false,
error: 'Etwas ist schiefgelaufen!'
});
});
// Datenbank initialisieren und Server starten
async function startServer() {
try {
await initDatabase();
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});
} catch (err) {
console.error('DB Init fehlgeschlagen:', err);
process.exit(1);
}
}
// Server nur starten, wenn nicht im Test-Modus
if (process.env.NODE_ENV !== 'test') {
startServer();
}
export default app;

View File

@ -0,0 +1,23 @@
import { Order } from '../types';
// Event system simulation
export function emitOrderCreated(order: Order): void {
console.log('Event: Order Created', order.orderNumber);
// Simulate different system
simulateShipping(order);
simulateWarehouse(order);
simulateEmail(order);
}
function simulateShipping(order: Order): void {
console.log(`Shipping: New order ${order.id}`);
}
function simulateWarehouse(order: Order): void {
console.log(`Warehouse: Prepare items for order ${order.id}`);
}
function simulateEmail(order: Order): void {
console.log(`Email: Sending confirmation to ${order.customerEmail}`);
}

View File

@ -0,0 +1,90 @@
import request from 'supertest';
import app from '../server';
import { initDatabase } from '../database';
beforeAll(async () => {
await initDatabase();
});
describe('POST /orders', () => {
it('should create a new order', async () => {
const orderData = {
customerId: 'CUST1',
customerName: 'Lui Denkwerk',
customerEmail: 'lui@test.com',
items: [
{
productId: 'TEST1',
quantity: 2,
price: 29.99
}
],
shippingAddress: {
street: 'teststraße 123',
city: 'Grevenbroich',
postalCode: '41515',
country: 'DE'
}
};
const response = await request(app)
.post('/orders')
.send(orderData)
.expect(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty('id');
expect(response.body.data.totalAmount).toBe(59.98);
});
it('should reject invalid email', async () => {
const invalidData = {
customerId: 'CUST1',
customerName: 'Lui Denkwerk',
customerEmail: 'nomailstring',
items: [{ productId: 'TEST1', quantity: 1, price: 29.99 }],
shippingAddress: {
street: 'teststraße 123',
city: 'Grevenbroich',
postalCode: '41515',
country: 'DE'
}
};
const response = await request(app)
.post('/orders')
.send(invalidData)
.expect(400);
expect(response.body.success).toBe(false);
});
it('should require all fields', async () => {
const response = await request(app)
.post('/orders')
.send({})
.expect(400);
expect(response.body.success).toBe(false);
});
it('should reject negative quantity', async () => {
const invalidData = {
customerId: 'CUST1',
customerName: 'Lui Denkwerk',
customerEmail: 'lui@test.com',
items: [{ productId: 'NOTISCH1', quantity: -1, price: 29.99 }],
shippingAddress: {
street: 'teststraße 123',
city: 'Grevenbroich',
postalCode: '41515',
country: 'DE'
}
};
await request(app)
.post('/orders')
.send(invalidData)
.expect(400);
});
});

25
backend/src/types.ts Normal file
View File

@ -0,0 +1,25 @@
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
export interface ShippingAddress {
street: string;
city: string;
postalCode: string;
country: string;
}
export interface Order {
id?: number;
orderNumber?: string;
customerId: string;
customerName: string;
customerEmail: string;
items: OrderItem[];
shippingAddress: ShippingAddress;
totalAmount: number;
status?: string;
createdAt?: string;
}

23
backend/src/validation.ts Normal file
View File

@ -0,0 +1,23 @@
import { z } from 'zod';
// Validation schemas mit Zod
export const orderSchema = z.object({
customerId: z.string().min(1, 'Kunden-ID ist erforderlich'),
customerName: z.string().min(2, 'Name zu kurz'),
customerEmail: z.string().email('Ungültige E-Mail'),
items: z.array(
z.object({
productId: z.string().min(1),
quantity: z.number().int().positive(),
price: z.number().positive(),
})
).min(1, 'Mindestens ein Artikel erforderlich'),
shippingAddress: z.object({
street: z.string().min(1),
city: z.string().min(1),
postalCode: z.string().min(1),
country: z.string().min(2),
}),
});
export type OrderInput = z.infer<typeof orderSchema>;

18
backend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

1
data/.gitkeep Normal file
View File

@ -0,0 +1 @@
# SQLite database will be created here

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3990:3990"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- PORT=3990
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3980:80"
depends_on:
- backend
restart: unless-stopped

115
docs/ANLEITUNG.md Normal file
View File

@ -0,0 +1,115 @@
# Setup Anleitung
Quick Start Guide für das OMS
## Voraussetzungen
- Node.js 18+
- npm
- Docker (optional)
## Installation
### Variante 1: Docker (empfohlen)
```bash
docker compose up
```
Das war's!
- Backend läuft auf http://localhost:3990
- Frontend läuft auf http://localhost:3980
### Variante 2: Lokal
```bash
# Dependencies installieren
npm run install:all
# In zwei separaten Terminals:
npm run dev # Backend (Terminal 1)
npm run dev:frontend # Frontend (Terminal 2)
```
## API Testen
### Mit Browser
1. Frontend öffnen: http://localhost:3980
2. Formular ausfüllen
3. Bestellung abschicken
### Mit curl
```bash
curl -X POST http://localhost:3990/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "DW001",
"customerName": "Lui Denkwerk",
"customerEmail": "lui@test.de",
"items": [{
"productId": "TISCH1",
"quantity": 1,
"price": 200.80
}],
"shippingAddress": {
"street": "Hauptstr. 1",
"city": "Grevenbroich",
"postalCode": "41515",
"country": "DE"
}
}'
```
## Tests ausführen
```bash
npm test
```
## Production Build
```bash
npm run build
npm start
```
## Troubleshooting
### Port schon belegt
Backend läuft auf Port 3990
Frontend auf 3980
Ändern in:
- `backend/src/server.ts` -> PORT Konstante
- `frontend/vite.config.ts` -> proxy config
### DB Fehler
Die SQLite DB wird automatisch unter `./data/orders.db` erstellt.
Falls Probleme: `rm -rf data/` und neu starten
### Docker Probleme
```bash
docker compose down
docker compose build --no-cache
docker compose up
```
## Wichtige Hinweise
- SQLite DB wird beim ersten Start automatisch angelegt
- Events werden in der Console geloggt (Simulation)
- Backend Port ist 3990
- Frontend Proxy leitet `/orders` requests an Backend weiter
- In Production PostgreSQL statt SQLite verwenden
## Nächste Schritte
Für Production:
1. PostgreSQL Setup
2. Environment Variables (`.env`)
3. JWT Auth implementieren
4. Message Queue für Events
5. Monitoring Setup
6. CI/CD Pipeline
---

141
docs/ARCHITEKTUR.md Normal file
View File

@ -0,0 +1,141 @@
# Architektur-Übersicht
Order Management System - Systemdesign und Entscheidungen
## Komponenten
### 1. API Layer (Backend)
- **Framework**: Express.js mit TypeScript
- **Port**: 3990
- **Validierung**: Zod für Runtime-Checks
- **API-Endpunkte**:
- `POST /orders` - Neue Bestellung erstellen
- `GET /health` - Health Check
### 2. Datenhaltung
- **Datenbank**: SQLite3
- **Pfad**: `./data/orders.db`
- **Schema**:
```sql
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
orderNumber TEXT UNIQUE,
customerId TEXT,
customerName TEXT,
customerEmail TEXT,
items TEXT, -- JSON to Convert
shippingAddress TEXT, -- JSON to Convert
totalAmount REAL,
status TEXT,
createdAt TEXT
);
```
**Warum SQLite?**
- Zero-config, einfach zu deployen
- Keine externe DB nötig für On-Premise
- Für Production würde ich auf PostgreSQL wechseln
### 3. Event-System
Simuliert mit Console-Logs. Events werden an folgende Systeme geschickt:
- Versand-Service
- Lager-System
- E-Mail-Service
### 4. Frontend
- **Framework**: React 18 + TypeScript
- **UI Library**: Material UI 5
- **Build**: Vite
## Deployment-Strategie
### Docker Compose
Zwei Container:
1. **Backend** (Node.js)
2. **Frontend** (Nginx)
### On-Premise Deployment
1. `docker compose up` auf Kundenserver
2. SQLite DB im Volume gemounted
3. Kein externes Setup nötig
### Skalierung (für später)
- **Horizontal**: Mehrere Backend-Instanzen mit Load Balancer
- **DB**: PostgreSQL
- **Cache**: Redis für Sessions/Performance
## Sicherheit
### Aktuell implementiert:
- Input-Validierung mit Zod
- CORS aktiviert
- SQL Injection durch Prepared Statements verhindert
### Für Production:
- [ ] JWT Authentication
- [ ] Rate Limiting (express-rate-limit)
- [ ] API Keys für Service-to-Service (X-API-Key)
## Monitoring & Logging
### Aktuell:
- Console Logs
- Error Stack Traces
## Fehlerhandling
### API-Level
```typescript
try {
// validation
// business logic
} catch (error) {
if (error.name === 'ZodError') {
return 400 // Bad Request
}
return 500 // Internal Error
}
```
### DB-Level
- Promises mit try/catch
- Graceful degradation
- Transaction Support (für später)
### Frontend
- Axios error handling
- User feedback mit Material UI Alerts
- Form validation
## Design-Entscheidungen
### TypeScript
**Pro**: Type Safety, Enteprise Version von Javascript :P
**Contra**: Etwas mehr Setup
### SQLite vs PostgreSQL
**Gewählt**: SQLite für einfaches Setup
**Grund**: Challenge-Kontext, On-Premise ohne Komplexität
**Migration**: In Production auf PostgreSQL umstellen
### Material UI vs Custom CSS
**Gewählt**: Material UI
**Grund**: Schnelle Entwicklung, professionelles Aussehen
### Event-Simulation
**Aktuell**: Console Logs
**Grund**: ausreichend für aufgabe
## Testing
### Backend
```bash
npm test
```
- Unit Tests mit Jest
- Integration Tests mit Supertest
- Mocked DB für schnelle Tests

BIN
docs/aufgabe1-1.drawio.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,235 @@
# Aufgabe 1 - Architekturentwurf
## Überblick
Ich hab mich für eine relativ klassische Architektur entschieden. Nichts fancy, aber sollte funktionieren. Der Gedanke war ein System zu bauen das:
- Beim Kunden einfach deployed werden kann (wer hat schon Lust auf kompliziertes Setup)
- Nicht overengineered ist (hab nur 4-6 Stunden)
- Aber trotzdem solid genug aussieht um damit zu arbeiten
## 1. Komponenten
### API Layer
**Was:** Express.js Backend mit TypeScript
**Warum Express?** Ehrlich gesagt weil ich's gut kenne. Hätte auch NestJS nehmen können aber das wäre für die Zeit zu viel gewesen. Express ist straightforward, es gibt Lösungen für alles auf StackOverflow, und ich kann schnell produktiv sein.
**Port:** 3990
Die API ist simpel:
- `POST /orders` - Hauptfunktion
- `GET /orders` - Hab ich als Bonus dazugemacht weil man ja die Orders auch sehen will
### Datenbank
**Was:** SQLite3
**Warum?** Ok, das war eine bewusste Entscheidung.
- Keine DB-Installation nötig
- Eine Datei, fertig
- Backup ist ein simple file copy
**Schema:**
Relativ simpel gehalten. Order hat die basics, Items und Address hab ich als JSON gespeichert. Ich weiß, manche Leute mögen kein JSON in der DB, aber für sowas ist's meiner Meinung nach ok - wir machen keine komplexen Joins darauf und es bleibt flexibel.
### Event-System
**Was:** Console Logs (Simulation)
**Warum so basic?** Weil ich zeigen wollte dass ich verstehe wie's funktionieren sollte, ohne jetzt ne ganze Message Queue aufzusetzen. In einem echten Projekt würde ich wahrscheinlich RabbitMQ nehmen (hab damit schon gearbeitet) oder wenn's viel Traffic ist Kafka, aber das ist dann halt ein anderes Level.
Das Prinzip ist klar: Order wird erstellt → Event wird gefeuert → andere Services können reagieren. Momentan logge ich nur:
- "Shipping System: neue Order"
- "Warehouse: Artikel vorbereiten"
- "Email: Bestätigung schicken"
Reicht glaub ich um die Idee rüberzubringen.
### Validierung
**Was:** Zod
**Warum?** Hab's in einem Tutorial gesehen und fand's gut. Du kriegst Runtime-Validation und die TypeScript-Types automatisch. Besser als alles manuell zu checken. Die Syntax ist auch relativ clean.
### Frontend
**Was:** React + Material UI + Vite
**Warum dieser Stack?**
- React weil ich's kann und's standard ist
- Material UI weil ich nicht alles selbst stylen wollte - in 30 min hab ich damit ein ok aussehendes UI
- Vite weil's schneller startet als Create React App und ich nicht ewig auf den dev server warten will
Nicht die perfekte Wahl für jedes Projekt aber für die Challenge vollkommen ok.
### Authentifizierung
**Was:** Nicht implementiert
**Warum?** Stand nicht in den Requirements und hätte echt Zeit gefressen. Wenn ich's machen müsste würde ich wahrscheinlich JWT nehmen, hab damit in anderen Projekten schon gearbeitet. Access token + refresh token pattern, standard halt.
## 2. Datenfluss beim Erstellen einer Bestellung
So läuft's ab (hoffe ich hab nichts vergessen):
```
1. User füllt Form aus
- React validiert schonmal das Basics (ist Email format ok, etc.)
2. Submit → POST /orders ans Backend
- CORS ist allowed (hab ich configured)
3. Backend: Zod Validation
- Checkt alle Felder
- Items array nicht leer?
- Prices sind numbers?
- etc.
- Wenn was fehlt → 400 zurück
4. Business Logic
- Ich rechne den total amount aus (reduce über items)
- Generiere eine Order Number (hab einfach timestamp + random number genommen)
- Status = 'pending'
5. Database Insert
- SQLite INSERT
- Items/Address werden zu JSON gestringified
- Wenn das fehlschlägt → 500 error
6. Events triggern
- Console.logs für die verschiedenen Services
- In echt würden hier Messages in Queues gehen
7. Response
- 201 Created
- Komplettes Order Object zurück
- Frontend zeigt Success Message
8. Frontend reagiert
- Form wird resettet
- Success Alert erscheint
- (Optional könnte man zu Orders List redirecten)
```
Dauert normalerweise unter 100ms weil alles lokal ist.
## 3. Deployment-Strategie
### Docker
Ich hab Docker Compose genommen weil:
- Ich's kenne und schnell damit bin
- Funktioniert überall gleich (Mac, Linux, Windows)
- Kunde muss nur `docker compose up` machen
- Keine Abhängigkeits-Konflikte
**Setup:**
2 Container - Backend und Frontend. Backend ist Node, Frontend wird mit nginx ausgeliefert. Hab multi-stage build verwendet um die Images kleiner zu halten.
### On-Premise beim Kunden
Da's beim Kunden on-premise laufen soll hab ich's simpel gehalten:
**Easy way:**
```bash
git clone
docker compose up -d
# done
```
Der Kunde braucht nur Docker installiert zu haben.
**Wenn's professional sein soll:**
- Images in private Registry
- Kunde zieht nur die Images (kein source code)
- .env file für Config
- Backup-Script für die DB
### CI/CD
Hab ich nicht implementiert (war ja nicht gefordert) aber wenn ich's machen würde:
- GitHub Actions (hab damit schon gearbeitet), workflow (gitea)
- Tests laufen lassen
- Images bauen
- Zu Registry pushen
- Optional auto-deploy zu staging
## 4. Skalierbarkeit, Sicherheit, Monitoring & Fehlerhandling
### Skalierbarkeit
**Aktueller Stand:** Einzelne Instanz, sollte für kleinere Kunden ok sein
**Wenn's größer wird:**
Ehrlich gesagt hab ich da keine super viel Erfahrung mit high-scale Systems, aber was ich mir vorstellen würde:
1. **Mehr Backend-Instanzen**
- Load Balancer davor (nginx kann das)
- SQLite wird dann zum Problem, also PostgreSQL
- Connection pooling nicht vergessen
2. **Database Optimization**
- Indexes auf die richtigen Felder (orderNumber, customerId)
- Vielleicht Read Replicas wenn's viele GET requests gibt
- Oder gleich managed DB nehmen (weniger Stress)
3. **Caching** (wenn nötig)
- Redis für häufige Queries
- Aber erstmal messen ob's wirklich gebraucht wird
Ich würde ehrlich gesagt erstmal abwarten wo die Bottlenecks sind bevor ich zu viel optimiere. Premature optimization und so.
### Sicherheit
**Was drin ist:**
- Input Validation mit Zod
- SQL Injection kann nicht passieren (prepared statements)
- CORS ist an (aber nicht restricted - müsste man noch machen)
Ich weiß dass da noch viel fehlt, aber für einen Prototyp sollte es ok sein. In einem echten Projekt würde man da mehr Zeit reinstecken.
### Monitoring
**Aktuell:** Console logs, super basic
Viel mehr kann ich dazu ehrlich gesagt nicht sagen weil ich's nicht in Production betreut hab. Aber das wären so die basics die Sinn machen würden.
### Fehlerhandling
**Mein Approach:**
Ich hab versucht die Errors sinnvoll zu kategorisieren:
```typescript
try {
// business logic
} catch (error) {
if (error.name === 'ZodError') {
// User hat Müll geschickt
return 400
}
// Irgendwas ist bei uns kaputt
console.error(error)
return 500
}
```
**Was ich beachtet hab:**
- 400 = User-Fehler (falsche inputs)
- 500 = Server-Fehler (unser Problem)
- Nie stack traces an den User schicken (security)
- Alles loggen für debugging
**Frontend Error Handling:**
- Try/catch um API calls
- User-freundliche Messages ("Bestellung konnte nicht erstellt werden" statt "500 Internal Server Error")
- Form bleibt gefüllt wenn's fehlschlägt (user muss nicht alles nochmal eingeben)
## Zusammenfassung & Ehrliche Einschätzung
**Was ich anders machen würde mit mehr Zeit:**
- PostgreSQL von Anfang an (Migration ist nervig)
- Echte Message Queue für Events
- Auth System
- Mehr Tests (hab nur die wichtigsten)
- Admin Dashboard wäre cool
**Nächste Schritte wenn's weitergeht:**
1. PostgreSQL Migration (wichtigste)
2. Message Queue (RabbitMQ maybe)
3. Auth hinzufügen (JWT)
4. Mehr Error handling
5. Production Monitoring setup
Ich denke für eine Challenge ist das ein ok Ergebnis. Nicht perfekt, aber funktioniert und ist ein guter Ausgangspunkt.
---

BIN
docs/aufgabe1.2.drawio.pdf Normal file

Binary file not shown.

5
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
dist-ssr
*.local
.env

16
frontend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:20 as build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Order Management System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@ -0,0 +1,20 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /orders {
proxy_pass http://backend:3990;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

1663
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "oms-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mui/material": "^5.15.3",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.5"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

334
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,334 @@
import { useState } from 'react';
import {
Container,
Paper,
Typography,
TextField,
Button,
Box,
Alert,
Card,
CardContent,
Grid,
MenuItem,
AppBar,
Toolbar,
} from '@mui/material';
import axios from 'axios';
import OrdersList from './OrdersList';
// Festes Produkt für die Challenge
const PRODUCT = {
id: 'DESK001',
name: 'Premium-Schreibtisch',
description: 'Höhenverstellbarer Schreibtisch mit Massivholzplatte',
price: 299.99,
};
interface FormData {
customerId: string;
customerName: string;
customerEmail: string;
quantity: number;
street: string;
city: string;
postalCode: string;
country: string;
}
function App() {
const [view, setView] = useState<'form' | 'orders'>('form');
const [formData, setFormData] = useState<FormData>({
customerId: '',
customerName: '',
customerEmail: '',
quantity: 1,
street: '',
city: '',
postalCode: '',
country: 'DE',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
setErrors({ ...errors, [field]: '' });
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.customerId) newErrors.customerId = 'Pflichtfeld';
if (!formData.customerName || formData.customerName.length < 2) {
newErrors.customerName = 'Mindestens 2 Zeichen';
}
if (!formData.customerEmail || !formData.customerEmail.includes('@')) {
newErrors.customerEmail = 'Ungültige E-Mail';
}
if (formData.quantity < 1) {
newErrors.quantity = 'Mindestens 1';
}
if (!formData.street) newErrors.street = 'Pflichtfeld';
if (!formData.city) newErrors.city = 'Pflichtfeld';
if (!formData.postalCode) newErrors.postalCode = 'Pflichtfeld';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSuccess(null);
if (!validate()) return;
setLoading(true);
try {
const orderData = {
customerId: formData.customerId,
customerName: formData.customerName,
customerEmail: formData.customerEmail,
items: [
{
productId: PRODUCT.id,
quantity: formData.quantity,
price: PRODUCT.price,
},
],
shippingAddress: {
street: formData.street,
city: formData.city,
postalCode: formData.postalCode,
country: formData.country,
},
};
const response = await axios.post('/orders', orderData);
setSuccess(`Bestellung erstellt! Nr: ${response.data.data.orderNumber}`);
// Form reset
setFormData({
customerId: '',
customerName: '',
customerEmail: '',
quantity: 1,
street: '',
city: '',
postalCode: '',
country: 'DE',
});
} catch (error: any) {
if (error.response?.data?.details) {
const apiErrors: Record<string, string> = {};
error.response.data.details.forEach((err: any) => {
const field = err.path?.[0];
if (field) apiErrors[field] = err.message;
});
setErrors(apiErrors);
} else {
setErrors({ general: 'Fehler beim Erstellen der Bestellung' });
}
} finally {
setLoading(false);
}
};
const total = PRODUCT.price * formData.quantity;
if (view === 'orders') {
return <OrdersList onBack={() => setView('form')} />;
}
return (
<>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Order Management System
</Typography>
<Button color="inherit" onClick={() => setView('orders')}>
Bestellungen
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="md" sx={{ py: 4 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Neue Bestellung erstellen
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Füllen Sie das Formular aus, um eine Bestellung aufzugeben
</Typography>
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
{success}
</Alert>
)}
{errors.general && (
<Alert severity="error" sx={{ mb: 3 }}>
{errors.general}
</Alert>
)}
<form onSubmit={handleSubmit}>
{/* Kundendaten */}
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
Kundendaten
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Kunden-ID"
value={formData.customerId}
onChange={handleChange('customerId')}
error={!!errors.customerId}
helperText={errors.customerId}
required
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Vollständiger Name"
value={formData.customerName}
onChange={handleChange('customerName')}
error={!!errors.customerName}
helperText={errors.customerName}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
type="email"
label="E-Mail"
value={formData.customerEmail}
onChange={handleChange('customerEmail')}
error={!!errors.customerEmail}
helperText={errors.customerEmail}
required
/>
</Grid>
</Grid>
{/* Produkt */}
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>
Produkt
</Typography>
<Card variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Typography variant="h6">{PRODUCT.name}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{PRODUCT.description}
</Typography>
<Typography variant="h5" color="primary">
{PRODUCT.price.toFixed(2)}
</Typography>
</CardContent>
</Card>
<TextField
type="number"
label="Menge"
value={formData.quantity}
onChange={handleChange('quantity')}
error={!!errors.quantity}
helperText={errors.quantity}
inputProps={{ min: 1 }}
sx={{ mb: 2 }}
required
/>
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, mb: 3 }}>
<Typography variant="h6" align="right">
Gesamt: {total.toFixed(2)}
</Typography>
</Box>
{/* Lieferadresse */}
<Typography variant="h6" gutterBottom>
Lieferadresse
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label="Straße und Hausnummer"
value={formData.street}
onChange={handleChange('street')}
error={!!errors.street}
helperText={errors.street}
required
/>
</Grid>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Postleitzahl"
value={formData.postalCode}
onChange={handleChange('postalCode')}
error={!!errors.postalCode}
helperText={errors.postalCode}
required
/>
</Grid>
<Grid item xs={12} sm={8}>
<TextField
fullWidth
label="Stadt"
value={formData.city}
onChange={handleChange('city')}
error={!!errors.city}
helperText={errors.city}
required
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
select
label="Land"
value={formData.country}
onChange={handleChange('country')}
required
>
<MenuItem value="DE">Deutschland</MenuItem>
<MenuItem value="AT">Österreich</MenuItem>
<MenuItem value="CH">Schweiz</MenuItem>
<MenuItem value="IT">Italien</MenuItem>
</TextField>
</Grid>
</Grid>
<Box sx={{ mt: 4, textAlign: 'right' }}>
<Button
type="submit"
variant="contained"
size="large"
disabled={loading}
>
{loading ? 'Wird bearbeitet...' : 'Bestellung bestätigen'}
</Button>
</Box>
</form>
</Paper>
</Container>
</>
);
}
export default App;

153
frontend/src/OrdersList.tsx Normal file
View File

@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import {
Container,
Paper,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
Box,
Button,
Alert,
} from '@mui/material';
import axios from 'axios';
interface Order {
id: number;
orderNumber: string;
customerId: string;
customerName: string;
customerEmail: string;
totalAmount: number;
status: string;
createdAt: string;
}
interface OrdersListProps {
onBack: () => void;
}
function OrdersList({ onBack }: OrdersListProps) {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOrders();
}, []);
const loadOrders = async () => {
try {
setLoading(true);
const response = await axios.get('/orders');
setOrders(response.data.data || []);
} catch (err) {
setError('Fehler beim Laden der Bestellungen');
console.error(err);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'pending':
return 'warning';
case 'completed':
return 'success';
case 'cancelled':
return 'error';
default:
return 'default';
}
};
if (loading) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Typography>Lädt...</Typography>
</Container>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h4" component="h1">
Bestellübersicht
</Typography>
<Button variant="outlined" onClick={onBack}>
Zurück
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{orders.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography color="text.secondary">
Noch keine Bestellungen vorhanden
</Typography>
</Paper>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Bestellnummer</TableCell>
<TableCell>Kunde</TableCell>
<TableCell>E-Mail</TableCell>
<TableCell align="right">Betrag</TableCell>
<TableCell>Status</TableCell>
<TableCell>Datum</TableCell>
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => (
<TableRow key={order.id} hover>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{order.orderNumber}
</Typography>
</TableCell>
<TableCell>{order.customerName}</TableCell>
<TableCell>{order.customerEmail}</TableCell>
<TableCell align="right">
{order.totalAmount.toFixed(2)}
</TableCell>
<TableCell>
<Chip
label={order.status}
color={getStatusColor(order.status)}
size="small"
/>
</TableCell>
<TableCell>
{new Date(order.createdAt).toLocaleDateString('de-DE')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Box sx={{ mt: 2, textAlign: 'right' }}>
<Typography variant="body2" color="text.secondary">
Gesamt: {orders.length} Bestellung{orders.length !== 1 ? 'en' : ''}
</Typography>
</Box>
</Container>
);
}
export default OrdersList;

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

22
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3980,
proxy: {
'/orders': 'http://localhost:3990'
}
}
})

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "aufgabe-29102025-main",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"install:all": "cd backend && npm install && cd ../frontend && npm install",
"dev": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"build": "cd backend && npm run build && cd ../frontend && npm run build",
"start": "cd backend && npm start",
"test": "cd backend && npm test",
"test:watch": "cd backend && npm run test:watch"
},
"keywords": [],
"author": "",
"license": "ISC"
}