This commit is contained in:
Schneider Roland 2025-09-28 22:05:59 +02:00
parent 39044ed565
commit 391a7350aa
28 changed files with 3968 additions and 0 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
DB_HOST=localhost
DB_PORT=3306
DB_USER=user
DB_PASSWORD=password
DB_NAME=reservations
JWT_SECRET=your_jwt_secret

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea
node_modules
.env
*.iml

View File

@ -0,0 +1,6 @@
CREATE TABLE event_types (
id CHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
defaultMaxReservationCount INT NOT NULL,
defaultColor VARCHAR(7) NOT NULL
);

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import {User} from "../../../src/entities/user.entity";
export class InitialUser1758772941067 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
let admin = new User();
admin.username = "admin";
admin.name = "Admin";
admin.email = 'admin@rschneider.hu'
await admin.hashPassword("123456");
await queryRunner.manager.save(admin);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@ -0,0 +1,16 @@
services:
flyway:
image: flyway/flyway
command: -url=jdbc:mysql://db -schemas=reservations -user=root -password=root -connectRetries=60 migrate
volumes:
- ../../db/migration:/flyway/sql
depends_on:
- db
db:
image: mariadb
restart: always
environment:
MARIADB_ROOT_PASSWORD: root
ports:
- "33306:3306"

4
flyway.conf Normal file
View File

@ -0,0 +1,4 @@
flyway.url=jdbc:mariadb://${env:DB_HOST}:${env:DB_PORT}/${env:DB_NAME}
flyway.user=${env:DB_USER}
flyway.password=${env:DB_PASSWORD}
flyway.locations=filesystem:db/migration

3497
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "reservation-api",
"version": "1.0.0",
"description": "Event reservation system API",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typeorm": "ts-node ./node_modules/typeorm/cli.js",
"flyway": "flyway"
},
"dependencies": {
"bcrypt": "^5.1.0",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"mariadb": "^3.0.2",
"mysql2": "^3.15.1",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.12"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.1",
"@types/node": "^18.14.6",
"ts-node": "^10.9.1",
"ts-node-dev": "^2.0.0",
"typescript": "^4.9.5"
}
}

2
reservations.http Normal file
View File

@ -0,0 +1,2 @@
###
GET localhost:3000/api/users

17
src/app.ts Normal file
View File

@ -0,0 +1,17 @@
import express from 'express';
import { errorHandler } from './middleware/error.middleware';
import authRoutes from './routes/auth.routes';
import userRoutes from './routes/user.routes';
import eventTypeRoutes from './routes/eventType.routes';
const app = express();
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/event-types', eventTypeRoutes);
app.use(errorHandler);
export default app;

16
src/config/index.ts Normal file
View File

@ -0,0 +1,16 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
db: {
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT || 23306),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || 'root',
name: process.env.DB_NAME || 'reservations',
},
jwt: {
secret: process.env.JWT_SECRET || 'secret',
},
};

View File

@ -0,0 +1,26 @@
import { Request, Response } from 'express';
import { AuthService } from '../services/auth.service';
export class AuthController {
private authService = new AuthService();
login = async (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ message: 'Username and password are required' });
}
try {
const token = await this.authService.login(username, password);
if (token) {
res.json({ token });
} else {
res.status(401).json({ message: 'Invalid credentials' });
}
} catch (error) {
res.status(500).json({ message: 'An error occurred during login' });
}
};
}

View File

@ -0,0 +1,30 @@
import { Request, Response } from 'express';
import { EventTypeService } from '../services/eventType.service';
export class EventTypeController {
private eventTypeService = new EventTypeService();
getAllEventTypes = async (req: Request, res: Response) => {
try {
const eventTypes = await this.eventTypeService.getAllEventTypes();
res.json(eventTypes);
} catch (error) {
res.status(500).json({ message: 'An error occurred while fetching event types' });
}
};
createEventType = async (req: Request, res: Response) => {
const { name, defaultMaxReservationCount, defaultColor } = req.body;
if (!name || !defaultMaxReservationCount || !defaultColor) {
return res.status(400).json({ message: 'Missing required fields' });
}
try {
const newEventType = await this.eventTypeService.createEventType({ name, defaultMaxReservationCount, defaultColor });
res.status(201).json(newEventType);
} catch (error) {
res.status(500).json({ message: 'An error occurred while creating the event type' });
}
};
}

View File

@ -0,0 +1,30 @@
import { Request, Response } from 'express';
import { UserService } from '../services/user.service';
export class UserController {
private userService = new UserService();
getAllUsers = async (req: Request, res: Response) => {
try {
const users = await this.userService.getAllUsers();
res.json(users);
} catch (error) {
res.status(500).json({ message: 'An error occurred while fetching users' });
}
};
createUser = async (req: Request, res: Response) => {
const { username, password, name, email, role } = req.body;
if (!username || !password || !name || !email) {
return res.status(400).json({ message: 'Missing required fields' });
}
try {
const newUser = await this.userService.createUser({ username, passwordHash: password, name, email, role });
res.status(201).json(newUser);
} catch (error) {
res.status(500).json({ message: 'An error occurred while creating the user' });
}
};
}

19
src/data-source.ts Normal file
View File

@ -0,0 +1,19 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { config } from './config';
import { User } from './entities/user.entity';
import { EventType } from './entities/eventType.entity';
export const AppDataSource = new DataSource({
type: 'mariadb',
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.password,
database: config.db.name,
synchronize: false, // Set to false for production, use migrations instead
logging: false,
entities: [User, EventType],
migrations: [],
subscribers: [],
});

View File

@ -0,0 +1,21 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
@Entity('event_types')
export class EventType {
@PrimaryColumn('varchar', { length: 36 })
id: string;
@Column({ unique: true })
name: string;
@Column()
defaultMaxReservationCount: number;
@Column()
defaultColor: string;
constructor() {
this.id = uuidv4();
}
}

View File

@ -0,0 +1,45 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';
export enum UserRole {
ADMIN = 'ADMIN',
STAFF = 'STAFF',
}
@Entity('users')
export class User {
@PrimaryColumn('varchar', { length: 36 })
id: string;
@Column({ unique: true })
username: string;
@Column()
passwordHash: string;
@Column()
name: string;
@Column({ unique: true })
email: string;
@Column({
type: 'enum',
enum: UserRole,
default: UserRole.STAFF,
})
role: UserRole;
constructor() {
this.id = uuidv4();
}
async hashPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, 10);
}
async validatePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.passwordHash);
}
}

15
src/index.ts Normal file
View File

@ -0,0 +1,15 @@
import app from './app';
import { AppDataSource } from './data-source';
const PORT = process.env.PORT || 3000;
AppDataSource.initialize()
.then(() => {
console.log('Data Source has been initialized!');
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
})
.catch((err) => {
console.error('Error during Data Source initialization:', err);
});

View File

@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { UserRole } from '../entities/user.entity';
interface AuthRequest extends Request {
user?: { id: string; role: UserRole };
}
export const protect = (req: AuthRequest, res: Response, next: NextFunction) => {
let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
try {
token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, config.jwt.secret) as { id: string; role: UserRole };
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ message: 'Not authorized, token failed' });
}
} else if (!token) {
res.status(401).json({ message: 'Not authorized, no token' });
}
};

View File

@ -0,0 +1,9 @@
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(err.statusCode || 500).json({
message: err.message || 'An unexpected error occurred',
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};

View File

@ -0,0 +1,15 @@
import { Request, Response, NextFunction } from 'express';
import { UserRole } from '../entities/user.entity';
interface AuthRequest extends Request {
user?: { id: string; role: UserRole };
}
export const authorize = (roles: UserRole[]) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ message: 'Not authorized, insufficient role' });
}
next();
};
};

View File

@ -0,0 +1,9 @@
import { Router } from 'express';
import { AuthController } from '../controllers/auth.controller';
const router = Router();
const authController = new AuthController();
router.post('/login', authController.login);
export default router;

View File

@ -0,0 +1,13 @@
import { Router } from 'express';
import { EventTypeController } from '../controllers/eventType.controller';
import { protect } from '../middleware/auth.middleware';
import { authorize } from '../middleware/role.middleware';
import { UserRole } from '../entities/user.entity';
const router = Router();
const eventTypeController = new EventTypeController();
router.get('/', protect, authorize([UserRole.ADMIN, UserRole.STAFF]), eventTypeController.getAllEventTypes);
router.post('/', protect, authorize([UserRole.ADMIN, UserRole.STAFF]), eventTypeController.createEventType);
export default router;

13
src/routes/user.routes.ts Normal file
View File

@ -0,0 +1,13 @@
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { protect } from '../middleware/auth.middleware';
import { authorize } from '../middleware/role.middleware';
import { UserRole } from '../entities/user.entity';
const router = Router();
const userController = new UserController();
router.get('/', protect, authorize([UserRole.ADMIN]), userController.getAllUsers);
router.post('/', protect, authorize([UserRole.ADMIN]), userController.createUser);
export default router;

View File

@ -0,0 +1,21 @@
import jwt from 'jsonwebtoken';
import { AppDataSource } from '../data-source';
import { User } from '../entities/user.entity';
import { config } from '../config';
export class AuthService {
private userRepository = AppDataSource.getRepository(User);
async login(username: string, password: string): Promise<string | null> {
const user = await this.userRepository.findOne({ where: { username } });
if (user && (await user.validatePassword(password))) {
const token = jwt.sign({ id: user.id, role: user.role }, config.jwt.secret, {
expiresIn: '1h',
});
return token;
}
return null;
}
}

View File

@ -0,0 +1,19 @@
import { AppDataSource } from '../data-source';
import { EventType } from '../entities/eventType.entity';
export class EventTypeService {
private eventTypeRepository = AppDataSource.getRepository(EventType);
async getAllEventTypes(): Promise<EventType[]> {
return this.eventTypeRepository.find();
}
async createEventType(eventTypeData: Partial<EventType>): Promise<EventType> {
const eventType = new EventType();
eventType.name = eventTypeData.name!;
eventType.defaultMaxReservationCount = eventTypeData.defaultMaxReservationCount!;
eventType.defaultColor = eventTypeData.defaultColor!;
return this.eventTypeRepository.save(eventType);
}
}

View File

@ -0,0 +1,21 @@
import { AppDataSource } from '../data-source';
import { User, UserRole } from '../entities/user.entity';
export class UserService {
private userRepository = AppDataSource.getRepository(User);
async getAllUsers(): Promise<User[]> {
return this.userRepository.find();
}
async createUser(userData: Partial<User>): Promise<User> {
const user = new User();
user.username = userData.username!;
user.name = userData.name!;
user.email = userData.email!;
user.role = userData.role || UserRole.STAFF;
await user.hashPassword(userData.passwordHash!); // The password should be passed here
return this.userRepository.save(user);
}
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}