first commit - backend init
This commit is contained in:
parent
3ba62a3609
commit
48a04969bc
27
.env.example
Normal file
27
.env.example
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# environment
|
||||||
|
NODE_ENV='development'
|
||||||
|
|
||||||
|
# Encryption keys are AES256 (32 bytes)
|
||||||
|
ENCRYPTION_KEY=d3680f1c027e865e1da5c2be8b0be20c43f70a8107071e61df15cab6df4357cf
|
||||||
|
|
||||||
|
# JWT secret
|
||||||
|
JWT_SECRET=sdfj94mfm430f72m3487rdsjiy7834n9rnf934n8r3n490fn4u83fh894hr9nf0
|
||||||
|
|
||||||
|
# SERVER_HOST=localhost
|
||||||
|
# SERVER_BASEPATH_API=v1/
|
||||||
|
# TIMEZONE=Europe/Amsterdam
|
||||||
|
|
||||||
|
FASTIFY_PORT=14000
|
||||||
|
|
||||||
|
# [ Database ]
|
||||||
|
|
||||||
|
# [local] database
|
||||||
|
POSTGRES_NAME=fusero-db
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=19090
|
||||||
|
POSTGRES_USER=admin
|
||||||
|
POSTGRES_PASSWORD=admin123
|
||||||
|
|
||||||
|
# [ APPS ]
|
||||||
|
|
||||||
|
|
27
.eslintrc.json
Normal file
27
.eslintrc.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"es6": true,
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
// ,
|
||||||
|
// "lines-between-class-members": [
|
||||||
|
// "error",
|
||||||
|
// "always",
|
||||||
|
// { "exceptAfterSingleLine": false }
|
||||||
|
// ],
|
||||||
|
// "padding-line-between-statements": [
|
||||||
|
// "error",
|
||||||
|
// { "blankLine": "always", "prev": "const", "next": "*" }
|
||||||
|
// ]
|
||||||
|
}
|
||||||
|
}
|
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 144,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Use Node.js 18.3 as the base image
|
||||||
|
FROM node:20-slim AS build
|
||||||
|
|
||||||
|
# Install Python and build tools for node-gyp
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y python3 make g++ && \
|
||||||
|
ln -s /usr/bin/python3 /usr/bin/python && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
ENV APP_DIR=/usr/src/app/
|
||||||
|
RUN mkdir -p ${APP_DIR}
|
||||||
|
|
||||||
|
# Install global dependencies like pm2, ts-node, and typescript as root
|
||||||
|
RUN npm install -g pm2 ts-node typescript
|
||||||
|
|
||||||
|
# Create a non-root user and switch to it
|
||||||
|
ENV APP_USER=appuser
|
||||||
|
RUN adduser --disabled-password --gecos '' ${APP_USER}
|
||||||
|
WORKDIR ${APP_DIR}
|
||||||
|
RUN chown -R ${APP_USER}:${APP_USER} ${APP_DIR}
|
||||||
|
|
||||||
|
# Switch to non-root user before copying files and installing dependencies
|
||||||
|
USER ${APP_USER}
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json and install dependencies as appuser
|
||||||
|
COPY --chown=${APP_USER}:${APP_USER} package.json package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the rest of the application code with appropriate ownership
|
||||||
|
COPY --chown=${APP_USER}:${APP_USER} . .
|
||||||
|
|
||||||
|
# Rebuild bcrypt and other native dependencies as appuser
|
||||||
|
RUN npm rebuild bcrypt --build-from-source
|
||||||
|
|
||||||
|
# Build the application using the npm script, assuming "build:ts" is defined
|
||||||
|
RUN npm run build:ts
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV CI=true
|
||||||
|
ENV PORT=14000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Expose the application's port
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Command to run the application using npm start
|
||||||
|
CMD ["npm", "start"]
|
346
README.md
Normal file
346
README.md
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
# Fusero Boilerplate App
|
||||||
|
|
||||||
|
This is a Fastify + MikroORM + PostgreSQL boilerplate, designed to run in Docker for local development.
|
||||||
|
It is set up to run alongside other projects without port or database conflicts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) (v18+ recommended)
|
||||||
|
- [Docker](https://www.docker.com/get-started)
|
||||||
|
- [npm](https://www.npmjs.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Clone the Repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd fusero-app-boilerplate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Setup the `.env` File
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` (or create `.env` if not present):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database connection for Docker
|
||||||
|
POSTGRES_NAME=fusero-boilerplate-db
|
||||||
|
POSTGRES_HOSTNAME=localhost
|
||||||
|
POSTGRES_PORT=19095
|
||||||
|
POSTGRES_USER=root
|
||||||
|
POSTGRES_PASSWORD=root123
|
||||||
|
|
||||||
|
# Test Database connection
|
||||||
|
POSTGRES_TEST_NAME=test-db
|
||||||
|
POSTGRES_TEST_PORT=19096
|
||||||
|
|
||||||
|
# Default admin user for seeding
|
||||||
|
DEFAULT_ADMIN_USERNAME=darren
|
||||||
|
DEFAULT_ADMIN_EMAIL=darren@fusero.nl
|
||||||
|
DEFAULT_ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# JWT secret
|
||||||
|
JWT_SECRET=your_jwt_secret_here
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Start the Database (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start two Postgres instances:
|
||||||
|
- Main database on port 19095
|
||||||
|
- Test database on port 19096
|
||||||
|
|
||||||
|
Both with dedicated Docker volumes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Run Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create # Create a new migration
|
||||||
|
npm run migration:up # Run migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create all tables in the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Seed the Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the default admin user and roles as specified in your `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Start the App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at [http://localhost:14000](http://localhost:14000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:14000/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "darren",
|
||||||
|
"password": "admin123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Authentication successful",
|
||||||
|
"data": {
|
||||||
|
"token": "your.jwt.token",
|
||||||
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "darren",
|
||||||
|
"email": "darren@fusero.nl",
|
||||||
|
"roles": ["admin"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
#### Create User
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:14000/api/v1/app/users \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer your.jwt.token" \
|
||||||
|
-d '{
|
||||||
|
"username": "newuser",
|
||||||
|
"password": "userpass123",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"roleName": "user"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "User created successfully",
|
||||||
|
"data": {
|
||||||
|
"id": 2,
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"roles": ["user"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get All Users (Requires admin role)
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:14000/api/v1/app/users \
|
||||||
|
-H "Authorization: Bearer your.jwt.token"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get User by ID
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:14000/api/v1/app/users/1 \
|
||||||
|
-H "Authorization: Bearer your.jwt.token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canvas Dummy Grades API
|
||||||
|
|
||||||
|
The canvas API provides endpoints for managing dummy grades. All endpoints are prefixed with `/api/v1/canvas-api/`.
|
||||||
|
|
||||||
|
#### Get All Dummy Grades
|
||||||
|
```bash
|
||||||
|
GET /api/v1/canvas-api/
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Dummy grades retrieved successfully",
|
||||||
|
"data": {
|
||||||
|
"grades": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"student_id": 101,
|
||||||
|
"course_id": 1,
|
||||||
|
"assignment_id": 1,
|
||||||
|
"score": 85,
|
||||||
|
"grade": "B",
|
||||||
|
"submitted_at": "2024-03-15T10:00:00Z",
|
||||||
|
"graded_at": "2024-03-16T14:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Add a New Dummy Grade
|
||||||
|
```bash
|
||||||
|
POST /api/v1/canvas-api/add
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"student_id": 123,
|
||||||
|
"course_id": 456,
|
||||||
|
"assignment_id": 789,
|
||||||
|
"score": 85
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Dummy grade added successfully",
|
||||||
|
"data": {
|
||||||
|
"id": 3,
|
||||||
|
"student_id": 123,
|
||||||
|
"course_id": 456,
|
||||||
|
"assignment_id": 789,
|
||||||
|
"score": 85,
|
||||||
|
"grade": "B",
|
||||||
|
"submitted_at": "2024-03-15T10:00:00Z",
|
||||||
|
"graded_at": "2024-03-16T14:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update a Dummy Grade
|
||||||
|
```bash
|
||||||
|
PUT /api/v1/canvas-api/update
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"student_id": 123,
|
||||||
|
"course_id": 456,
|
||||||
|
"assignment_id": 789,
|
||||||
|
"score": 90
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Dummy grade updated successfully",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"student_id": 123,
|
||||||
|
"course_id": 456,
|
||||||
|
"assignment_id": 789,
|
||||||
|
"score": 90,
|
||||||
|
"grade": "A",
|
||||||
|
"submitted_at": "2024-03-15T10:00:00Z",
|
||||||
|
"graded_at": "2024-03-16T14:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete a Dummy Grade
|
||||||
|
```bash
|
||||||
|
DELETE /api/v1/canvas-api/delete
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Dummy grade deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing
|
||||||
|
|
||||||
|
Run tests with:
|
||||||
|
```bash
|
||||||
|
npm test # Run all tests
|
||||||
|
npm run test:watch # Run tests in watch mode
|
||||||
|
npm run test:coverage # Run tests with coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
The test database is automatically used for running tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Authentication & Authorization
|
||||||
|
|
||||||
|
- All endpoints except login require a valid JWT token
|
||||||
|
- The JWT token should be included in the Authorization header as: `Bearer your.jwt.token`
|
||||||
|
- Some endpoints require specific roles (admin/user)
|
||||||
|
- JWT tokens expire after 1 hour
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Database Connection Issues**
|
||||||
|
- Ensure Docker is running
|
||||||
|
- Check if the Postgres containers are up: `docker ps`
|
||||||
|
- Verify database credentials in `.env`
|
||||||
|
|
||||||
|
2. **Authentication Issues**
|
||||||
|
- Ensure JWT_SECRET is set in `.env`
|
||||||
|
- Check if the user exists in the database
|
||||||
|
- Verify the password is correct
|
||||||
|
|
||||||
|
3. **Role-based Access Issues**
|
||||||
|
- Ensure the user has the required role
|
||||||
|
- Check if the JWT token includes the correct roles
|
||||||
|
- Verify the token hasn't expired
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
- Application logs can be viewed in the terminal where `npm run dev` was executed
|
||||||
|
- Database logs can be viewed using: `docker logs fusero-boilerplate-db`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Notes
|
||||||
|
|
||||||
|
- The app uses separate databases, ports, and Docker volumes from any other Fusero projects, so it can run in parallel.
|
||||||
|
- The default admin user is created by the seed script and can be changed via `.env`.
|
||||||
|
- For production, use `docker-compose.yml` and adjust ports/credentials as needed.
|
||||||
|
- The app includes TypeScript, ESLint, and Prettier for code quality.
|
||||||
|
|
||||||
|
---
|
36
docker-compose.dev.yml
Normal file
36
docker-compose.dev.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
fusero-boilerplate-db:
|
||||||
|
image: postgres:15
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- fusero_boilerplate_pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '19095:5432' # New port
|
||||||
|
container_name: fusero-boilerplate-db
|
||||||
|
networks:
|
||||||
|
- fusero-boilerplate-network
|
||||||
|
|
||||||
|
fusero-boilerplate-test-db:
|
||||||
|
image: postgres:15
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- fusero_boilerplate_test_pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '19096:5432' # New test port
|
||||||
|
container_name: fusero-boilerplate-test-db
|
||||||
|
networks:
|
||||||
|
- fusero-boilerplate-network
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=test-db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fusero_boilerplate_pgdata:
|
||||||
|
external: true
|
||||||
|
fusero_boilerplate_test_pgdata:
|
||||||
|
external: false
|
||||||
|
|
||||||
|
networks:
|
||||||
|
fusero-boilerplate-network:
|
||||||
|
name: fusero-boilerplate-network
|
91
docker-compose.yml
Normal file
91
docker-compose.yml
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
fusero-frontend:
|
||||||
|
container_name: fusero-frontend
|
||||||
|
env_file: ../fusero-frontend/.env
|
||||||
|
build:
|
||||||
|
context: ../fusero-frontend
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
ports:
|
||||||
|
- '3000:80'
|
||||||
|
networks:
|
||||||
|
- fusero-network
|
||||||
|
|
||||||
|
fusero-app-boilerplate:
|
||||||
|
environment:
|
||||||
|
- POSTGRES_HOST=fusero-app-db
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- '5000:14000'
|
||||||
|
depends_on:
|
||||||
|
- fusero-app-db
|
||||||
|
container_name: fusero-app-boilerplate
|
||||||
|
networks:
|
||||||
|
- fusero-network
|
||||||
|
|
||||||
|
fusero-app-db:
|
||||||
|
image: postgres:15
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- fusero_app_pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '19090:5432'
|
||||||
|
container_name: fusero-app-db
|
||||||
|
networks:
|
||||||
|
- fusero-network
|
||||||
|
|
||||||
|
fusero-app-test-db:
|
||||||
|
image: postgres:15
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- fusero_app_test_pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '19091:5432'
|
||||||
|
container_name: fusero-app-test-db
|
||||||
|
networks:
|
||||||
|
- fusero-network
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=test-db
|
||||||
|
|
||||||
|
# ngrok:
|
||||||
|
# image: ngrok/ngrok:latest
|
||||||
|
# restart: unless-stopped
|
||||||
|
# command:
|
||||||
|
# - 'start'
|
||||||
|
# - '--all'
|
||||||
|
# - '--config'
|
||||||
|
# - '/etc/ngrok.yml'
|
||||||
|
# volumes:
|
||||||
|
# - ./ngrok.yml:/etc/ngrok.yml
|
||||||
|
# ports:
|
||||||
|
# - 19095:4040
|
||||||
|
# networks:
|
||||||
|
# - fusero-network
|
||||||
|
|
||||||
|
# fusero-redis:
|
||||||
|
# image: redis:7-alpine
|
||||||
|
# restart: always
|
||||||
|
# ports:
|
||||||
|
# - '6379:6379'
|
||||||
|
# volumes:
|
||||||
|
# - redis_data:/data
|
||||||
|
# container_name: fusero-redis
|
||||||
|
# networks:
|
||||||
|
# - fusero-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
fusero_app_pgdata:
|
||||||
|
external: true
|
||||||
|
fusero_app_test_pgdata:
|
||||||
|
external: false
|
||||||
|
|
||||||
|
networks:
|
||||||
|
fusero-network:
|
||||||
|
name: fusero-network
|
14
jest.config.js
Normal file
14
jest.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/tests/**/*.test.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1'
|
||||||
|
},
|
||||||
|
setupFiles: ['<rootDir>/src/tests/setup.ts'],
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: 'tsconfig.json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
45
mikro-orm.config.ts
Normal file
45
mikro-orm.config.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// import 'dotenv/config';
|
||||||
|
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Force reload `.env` even if it was previously loaded
|
||||||
|
dotenv.config({ override: true });
|
||||||
|
import { Options } from '@mikro-orm/core';
|
||||||
|
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||||
|
import { Migrator } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
const config: Options = {
|
||||||
|
driver: PostgreSqlDriver,
|
||||||
|
entities: ['./dist/src/apps/_app/entities/**/*.js'],
|
||||||
|
entitiesTs: ['./src/apps/_app/entities/**/*.ts'],
|
||||||
|
extensions: [Migrator],
|
||||||
|
baseDir: process.cwd(),
|
||||||
|
discovery: {
|
||||||
|
warnWhenNoEntities: true,
|
||||||
|
disableDynamicFileAccess: false,
|
||||||
|
},
|
||||||
|
dbName: process.env.POSTGRES_NAME,
|
||||||
|
host: process.env.POSTGRES_HOSTNAME,
|
||||||
|
port: Number(process.env.POSTGRES_PORT),
|
||||||
|
// port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
|
||||||
|
user: process.env.POSTGRES_USER,
|
||||||
|
password: process.env.POSTGRES_PASSWORD,
|
||||||
|
debug: !isProduction,
|
||||||
|
migrations: {
|
||||||
|
tableName: process.env.POSTGRES_NAME,
|
||||||
|
path: isProduction ? './dist/src/database/migrations' : './src/database/migrations',
|
||||||
|
glob: '!(*.d).{js,ts}',
|
||||||
|
transactional: true,
|
||||||
|
disableForeignKeys: true,
|
||||||
|
allOrNothing: true,
|
||||||
|
dropTables: true,
|
||||||
|
safe: false,
|
||||||
|
snapshot: true,
|
||||||
|
emit: 'ts',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
97
package.json
Normal file
97
package.json
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"name": "x",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "This project was bootstrapped with Fastify-CLI.",
|
||||||
|
"main": "app.ts",
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"_moduleAliases": {
|
||||||
|
"@": "src"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"fc": "fastify cli",
|
||||||
|
"test": "jest",
|
||||||
|
"test:unit": "tap test/services/**/*.ts",
|
||||||
|
"test:integration": "tap test/integration/**/*.ts",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"start": "npm run build:ts && fastify start -l info -r tsconfig-paths/register dist/src/app.js",
|
||||||
|
"prebuild": "npm run lint",
|
||||||
|
"build:ts": "rimraf dist && tsc",
|
||||||
|
"watch:ts": "tsc -w",
|
||||||
|
"dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"",
|
||||||
|
"dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P -r tsconfig-paths/register dist/src/app.js",
|
||||||
|
"app:generate": "node ./utils/generate-app.js",
|
||||||
|
"lint": "eslint src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"lint:fix": "eslint src/**/*.{js,jsx,ts,tsx} --fix",
|
||||||
|
"migration:create": "npx mikro-orm migration:create",
|
||||||
|
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts",
|
||||||
|
"test:db": "ts-node -r tsconfig-paths/register src/database/test-connection.ts"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/autoload": "^5.0.0",
|
||||||
|
"@fastify/cookie": "^9.3.1",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
"@fastify/postgres": "^5.2.2",
|
||||||
|
"@fastify/sensible": "^5.0.0",
|
||||||
|
"@fastify/swagger": "^9.2.0",
|
||||||
|
"@fastify/swagger-ui": "^4.2.0",
|
||||||
|
"@fastify/type-provider-typebox": "^4.1.0",
|
||||||
|
"@mikro-orm/cli": "^6.2.1",
|
||||||
|
"@mikro-orm/core": "^6.2.1",
|
||||||
|
"@mikro-orm/migrations": "^6.2.1",
|
||||||
|
"@mikro-orm/postgresql": "^6.2.1",
|
||||||
|
"@sentry/node": "^7.112.1",
|
||||||
|
"@sentry/profiling-node": "^7.112.1",
|
||||||
|
"@sinclair/typebox": "^0.32.35",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"fastify": "^4.26.2",
|
||||||
|
"fastify-cli": "^6.1.1",
|
||||||
|
"fastify-jwt": "^4.2.0",
|
||||||
|
"fastify-plugin": "^4.0.0",
|
||||||
|
"glob": "^9.3.5",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mikro-orm": "^6.2.0",
|
||||||
|
"module-alias": "^2.2.3",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"redoc": "^2.2.0",
|
||||||
|
"reflect-metadata": "^0.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
||||||
|
"c8": "^9.0.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"fastify-tsconfig": "^2.0.0",
|
||||||
|
"husky": "^9.1.6",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
|
"pino-pretty": "^11.3.0",
|
||||||
|
"rimraf": "^5.0.10",
|
||||||
|
"tap": "^21.0.1",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"husky": {
|
||||||
|
"hooks": {
|
||||||
|
"pre-commit": "lint-staged"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"src/**/*.{ts,tsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"git add"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
121
src/app.ts
Normal file
121
src/app.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
|
import AutoLoad from '@fastify/autoload';
|
||||||
|
import fastifyCors from '@fastify/cors';
|
||||||
|
import { MikroORM, RequestContext } from '@mikro-orm/core';
|
||||||
|
import mikroOrmConfig from '../mikro-orm.config';
|
||||||
|
import fjwt from '@fastify/jwt';
|
||||||
|
import fCookie from '@fastify/cookie';
|
||||||
|
import router from './router';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import fastify from 'fastify';
|
||||||
|
|
||||||
|
// Helper function to generate the directory tree in HTML
|
||||||
|
function generateDirectoryTree(dirPath: string): string {
|
||||||
|
const items = fs.readdirSync(dirPath);
|
||||||
|
let html = '<ul>';
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const itemPath = path.join(dirPath, item);
|
||||||
|
const isDirectory = fs.statSync(itemPath).isDirectory();
|
||||||
|
html += `<li>${isDirectory ? `<strong>${item}</strong>` : item}`;
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
html += generateDirectoryTree(itemPath); // Recursive call for directories
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</ul>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML wrapper function
|
||||||
|
function generateHtmlPage(directoryHtml: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Directory Structure</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; }
|
||||||
|
ul { list-style-type: none; padding-left: 20px; }
|
||||||
|
li { margin: 5px 0; cursor: pointer; }
|
||||||
|
li strong { color: #007bff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Directory Structure</h1>
|
||||||
|
${directoryHtml}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('li strong').forEach(folder => {
|
||||||
|
folder.addEventListener('click', () => {
|
||||||
|
const subList = folder.nextElementSibling;
|
||||||
|
if (subList) {
|
||||||
|
subList.style.display = subList.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify app setup
|
||||||
|
const app: FastifyPluginAsync = async (app, opts): Promise<void> => {
|
||||||
|
console.log("DB CONFIG: ", {
|
||||||
|
dbName: process.env.POSTGRES_NAME,
|
||||||
|
host: process.env.POSTGRES_HOSTNAME,
|
||||||
|
port: process.env.POSTGRES_PORT,
|
||||||
|
user: process.env.POSTGRES_USER,
|
||||||
|
password: process.env.POSTGRES_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orm = await MikroORM.init({ ...mikroOrmConfig });
|
||||||
|
|
||||||
|
// Register CORS, JWT, and Cookies
|
||||||
|
app.register(fastifyCors, { origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE'] });
|
||||||
|
app.register(fjwt, { secret: process.env.JWT_SECRET || 'your-secret-here' });
|
||||||
|
app.register(fCookie);
|
||||||
|
|
||||||
|
// Database and request context
|
||||||
|
app.decorate('orm', orm);
|
||||||
|
app.decorateRequest('em', null);
|
||||||
|
app.addHook('onRequest', (request, reply, done) => {
|
||||||
|
request.em = orm.em.fork();
|
||||||
|
RequestContext.create(request.em, done);
|
||||||
|
});
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
await orm.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load plugins
|
||||||
|
void app.register(AutoLoad, {
|
||||||
|
dir: join(__dirname, 'plugins'),
|
||||||
|
options: opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directory structure route under `/system/files`
|
||||||
|
app.get('/system/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const directoryHtml = generateDirectoryTree(path.join(__dirname, '../')); // Adjust path as needed
|
||||||
|
const htmlContent = generateHtmlPage(directoryHtml);
|
||||||
|
|
||||||
|
reply.type('text/html').send(htmlContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
void app.register(router, { prefix: '/api/v1' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const server = fastify();
|
||||||
|
await server.register(app);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
28
src/apps/_app/entities/_BaseEntity.ts
Normal file
28
src/apps/_app/entities/_BaseEntity.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
PrimaryKey,
|
||||||
|
Property,
|
||||||
|
BaseEntity as MikroOrmBaseEntity,
|
||||||
|
BeforeCreate,
|
||||||
|
BeforeUpdate,
|
||||||
|
} from '@mikro-orm/core';
|
||||||
|
|
||||||
|
export abstract class BaseEntity extends MikroOrmBaseEntity {
|
||||||
|
@PrimaryKey()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@Property({ hidden: true })
|
||||||
|
createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({ hidden: true })
|
||||||
|
updatedAt: Date = new Date();
|
||||||
|
|
||||||
|
@BeforeCreate()
|
||||||
|
setCreationDate() {
|
||||||
|
this.createdAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
setUpdateDate() {
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
12
src/apps/_app/entities/apikey/_APIKey.ts
Normal file
12
src/apps/_app/entities/apikey/_APIKey.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Entity, Property, ManyToOne } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { User } from '../user/_User';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class APIKey extends BaseEntity {
|
||||||
|
@Property()
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
user!: User;
|
||||||
|
}
|
12
src/apps/_app/entities/app/_App.ts
Normal file
12
src/apps/_app/entities/app/_App.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Entity, Property, OneToMany, Collection } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { TenantApp } from '../tenant/TenantApps';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class App extends BaseEntity {
|
||||||
|
@Property()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@OneToMany(() => TenantApp, (tenantApp) => tenantApp.app)
|
||||||
|
tenantApps = new Collection<TenantApp>(this);
|
||||||
|
}
|
45
src/apps/_app/entities/credentials/_Credential.ts
Normal file
45
src/apps/_app/entities/credentials/_Credential.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Entity, ManyToOne, Property } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { User } from '../user/_User';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { App } from '../app/_App';
|
||||||
|
|
||||||
|
interface CryptoMethods {
|
||||||
|
encrypt(text: string): string;
|
||||||
|
decrypt(text: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance extends CryptoMethods {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Credential extends BaseEntity {
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => App)
|
||||||
|
app!: App;
|
||||||
|
|
||||||
|
@Property({ columnType: 'text', nullable: true })
|
||||||
|
encryptedCredentials?: string;
|
||||||
|
|
||||||
|
getDecryptedCredentials(): string | undefined {
|
||||||
|
if (!this.encryptedCredentials) return undefined;
|
||||||
|
return this.decrypt(this.encryptedCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEncryptedCredentials(value: string): void {
|
||||||
|
this.encryptedCredentials = this.encrypt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(text: string): string {
|
||||||
|
return (this.constructor as typeof Credential).fastifyInstance.encrypt(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrypt(text: string): string {
|
||||||
|
return (this.constructor as typeof Credential).fastifyInstance.decrypt(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fastifyInstance: FastifyInstance;
|
||||||
|
}
|
17
src/apps/_app/entities/tenant/TenantApps.ts
Normal file
17
src/apps/_app/entities/tenant/TenantApps.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Entity, ManyToOne } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { Tenant } from './_Tenant';
|
||||||
|
import { App } from '../app/_App';
|
||||||
|
import { User } from '../user/_User';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class TenantApp extends BaseEntity {
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
tenant!: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => App)
|
||||||
|
app!: App;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
user!: User;
|
||||||
|
}
|
12
src/apps/_app/entities/tenant/_Tenant.ts
Normal file
12
src/apps/_app/entities/tenant/_Tenant.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Entity, Property, OneToMany, Collection } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { TenantApp } from './TenantApps';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Tenant extends BaseEntity {
|
||||||
|
@Property()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@OneToMany(() => TenantApp, (tenantApp) => tenantApp.tenant)
|
||||||
|
tenantApps = new Collection<TenantApp>(this);
|
||||||
|
}
|
14
src/apps/_app/entities/user/UserRole.ts
Normal file
14
src/apps/_app/entities/user/UserRole.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Entity, Property, ManyToMany, Collection } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { User } from './_User';
|
||||||
|
|
||||||
|
import type { UserRoleType } from '@/constants/roles';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class UserRole extends BaseEntity {
|
||||||
|
@Property()
|
||||||
|
name!: UserRoleType;
|
||||||
|
|
||||||
|
@ManyToMany(() => User, (user) => user.roles)
|
||||||
|
users = new Collection<User>(this);
|
||||||
|
}
|
46
src/apps/_app/entities/user/_User.ts
Normal file
46
src/apps/_app/entities/user/_User.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// User.ts
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Property,
|
||||||
|
ManyToMany,
|
||||||
|
Collection,
|
||||||
|
OneToMany,
|
||||||
|
BeforeCreate,
|
||||||
|
BeforeUpdate,
|
||||||
|
Unique,
|
||||||
|
} from '@mikro-orm/core';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { BaseEntity } from '../_BaseEntity';
|
||||||
|
import { TenantApp } from '../tenant/TenantApps';
|
||||||
|
import { UserRole } from './UserRole';
|
||||||
|
import { Credential } from '../credentials/_Credential';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class User extends BaseEntity {
|
||||||
|
@Property()
|
||||||
|
@Unique()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@Property({ hidden: true })
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@ManyToMany(() => UserRole, (role) => role.users, { owner: true })
|
||||||
|
roles = new Collection<UserRole>(this);
|
||||||
|
|
||||||
|
@OneToMany(() => TenantApp, (tenantApp) => tenantApp.user)
|
||||||
|
tenantApps = new Collection<TenantApp>(this);
|
||||||
|
|
||||||
|
@OneToMany(() => Credential, (credential) => credential.user)
|
||||||
|
credentials = new Collection<Credential>(this);
|
||||||
|
|
||||||
|
@BeforeCreate()
|
||||||
|
@BeforeUpdate()
|
||||||
|
async hashPassword() {
|
||||||
|
if (this.password && !this.password.startsWith('$2b$')) {
|
||||||
|
this.password = await bcrypt.hash(this.password, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
109
src/apps/_app/http/controllers/UserController.ts
Normal file
109
src/apps/_app/http/controllers/UserController.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { UserService } from '../../services/UserService';
|
||||||
|
import { UserRoleType } from '@/constants/roles';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export type UserRequestBody = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email: string;
|
||||||
|
roleName: UserRoleType;
|
||||||
|
};
|
||||||
|
export class UserController {
|
||||||
|
constructor(private userService: UserService) {}
|
||||||
|
|
||||||
|
async getAllUsers(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const users = await this.userService.getAllUsers();
|
||||||
|
return reply.send(users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getAllUsers:', error);
|
||||||
|
return reply.code(500).send(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(req: FastifyRequest<{ Params: { id: number } }>, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const user = await this.userService.getUserById(req.params.id);
|
||||||
|
if (user) {
|
||||||
|
return reply.send(user);
|
||||||
|
} else {
|
||||||
|
return reply.code(404).send({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getUserById:', error);
|
||||||
|
return reply.code(500).send(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(req: FastifyRequest<{ Body: UserRequestBody }>, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { username, password, email, roleName } = req.body as UserRequestBody;
|
||||||
|
|
||||||
|
const existingUser = await this.userService.getUserByUsername(username);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return reply.code(409).send({ message: 'Username already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userService.createUserWithRole(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
roleName
|
||||||
|
);
|
||||||
|
return reply.code(201).send(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in createUser:', error);
|
||||||
|
return reply.code(500).send(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginUser(
|
||||||
|
req: FastifyRequest<{ Body: { username: string; password: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
const user = await this.userService.authenticateUser(username, password);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(401).send({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid username or password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roles: user.roles.map((role) => role.name),
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET as string,
|
||||||
|
{ expiresIn: '1h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
message: 'Authentication successful',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.roles.map((role) => role.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in loginUser:', error);
|
||||||
|
return reply.code(401).send({
|
||||||
|
success: false,
|
||||||
|
message: 'Authentication failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
src/apps/_app/repositories/APIKeyRepository.ts
Normal file
8
src/apps/_app/repositories/APIKeyRepository.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { EntityRepository } from '@mikro-orm/core';
|
||||||
|
import { APIKey } from '@/apps/_app/entities/apikey/_APIKey';
|
||||||
|
|
||||||
|
export class APIKeyRepository extends EntityRepository<APIKey> {
|
||||||
|
async findAPIKeyByUserId(userId: number): Promise<APIKey | null> {
|
||||||
|
return this.findOne({ user: userId });
|
||||||
|
}
|
||||||
|
}
|
18
src/apps/_app/repositories/UserRepository.ts
Normal file
18
src/apps/_app/repositories/UserRepository.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { EntityRepository } from '@mikro-orm/core';
|
||||||
|
import { User } from '@/apps/_app/entities/user/_User';
|
||||||
|
|
||||||
|
export class UserRepository extends EntityRepository<User> {
|
||||||
|
async findAllUsers(): Promise<User[]> {
|
||||||
|
return this.findAll({
|
||||||
|
populate: ['roles'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserById(id: number): Promise<User | null> {
|
||||||
|
return this.findOne({ id }, { populate: ['roles'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByUsername(username: string): Promise<User | null> {
|
||||||
|
return this.findOne({ username }, { populate: ['roles'] });
|
||||||
|
}
|
||||||
|
}
|
9
src/apps/_app/repositories/UserRoleRepository.ts
Normal file
9
src/apps/_app/repositories/UserRoleRepository.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { EntityRepository } from '@mikro-orm/core';
|
||||||
|
import { UserRole } from '@/apps/_app/entities/user/UserRole';
|
||||||
|
import { UserRoleType } from '@/constants/roles';
|
||||||
|
|
||||||
|
export class UserRoleRepository extends EntityRepository<UserRole> {
|
||||||
|
async findRoleByName(roleName: UserRoleType): Promise<UserRole | null> {
|
||||||
|
return this.findOne({ name: roleName });
|
||||||
|
}
|
||||||
|
}
|
61
src/apps/_app/routes/UserRoutes.ts
Normal file
61
src/apps/_app/routes/UserRoutes.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { UserController, UserRequestBody } from '../http/controllers/UserController';
|
||||||
|
import { UserService } from '../services/UserService';
|
||||||
|
import { UserRepository } from '../repositories/UserRepository';
|
||||||
|
import { UserRoleRepository } from '../repositories/UserRoleRepository';
|
||||||
|
import { User } from '../entities/user/_User';
|
||||||
|
import { UserRole } from '../entities/user/UserRole';
|
||||||
|
import auth from '../../../middleware/Auth';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
userController: (request: FastifyRequest) => UserController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.decorate('userController', (request: FastifyRequest) => {
|
||||||
|
const userRepository = new UserRepository(request.em, User);
|
||||||
|
const userRoleRepository = new UserRoleRepository(request.em, UserRole);
|
||||||
|
const userService = new UserService(request.em, userRepository, userRoleRepository);
|
||||||
|
return new UserController(userService);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/',
|
||||||
|
{ preHandler: auth(['admin', 'user']) },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const userController = app.userController(request);
|
||||||
|
return userController.getAllUsers(request, reply);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/:id',
|
||||||
|
async (request: FastifyRequest<{ Params: { id: number } }>, reply: FastifyReply) => {
|
||||||
|
const userController = app.userController(request);
|
||||||
|
return userController.getUserById(request, reply);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/',
|
||||||
|
async (request: FastifyRequest<{ Body: UserRequestBody }>, reply: FastifyReply) => {
|
||||||
|
const userController = app.userController(request);
|
||||||
|
return userController.createUser(request, reply);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/login',
|
||||||
|
async (
|
||||||
|
request: FastifyRequest<{ Body: { username: string; password: string } }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) => {
|
||||||
|
const userController = app.userController(request);
|
||||||
|
return userController.loginUser(request, reply);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default userRoutes;
|
18
src/apps/_app/services/APIKeyService.ts
Normal file
18
src/apps/_app/services/APIKeyService.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { APIKeyRepository } from '../repositories/APIKeyRepository';
|
||||||
|
import { APIKey } from '../entities/apikey/_APIKey';
|
||||||
|
|
||||||
|
export class APIKeyService {
|
||||||
|
private apiKeyRepository: APIKeyRepository;
|
||||||
|
private em: EntityManager;
|
||||||
|
|
||||||
|
constructor(em: EntityManager) {
|
||||||
|
this.em = em;
|
||||||
|
this.apiKeyRepository = this.em.getRepository(APIKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAPIKeyForUser(userId: number): Promise<string | null> {
|
||||||
|
const apiKey = await this.apiKeyRepository.findAPIKeyByUserId(userId);
|
||||||
|
return apiKey ? apiKey.key : null;
|
||||||
|
}
|
||||||
|
}
|
83
src/apps/_app/services/UserService.ts
Normal file
83
src/apps/_app/services/UserService.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { User } from '../entities/user/_User';
|
||||||
|
import { UserRepository } from '../repositories/UserRepository';
|
||||||
|
import { UserRoleRepository } from '../repositories/UserRoleRepository';
|
||||||
|
import { UserRoleType } from '@/constants/roles';
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly userRoleRepository: UserRoleRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getAllUsers() {
|
||||||
|
try {
|
||||||
|
return this.userRepository.findAllUsers();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error fetching users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(id: number) {
|
||||||
|
try {
|
||||||
|
return await this.userRepository.findUserById(id);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error fetching user by ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByUsername(username: string) {
|
||||||
|
try {
|
||||||
|
return await this.userRepository.findUserByUsername(username);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error fetching user by username');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUserWithRole(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
email: string,
|
||||||
|
roleName: UserRoleType
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = new User();
|
||||||
|
user.username = username;
|
||||||
|
user.password = password; // Let the entity handle password hashing
|
||||||
|
user.email = email;
|
||||||
|
|
||||||
|
const role = await this.userRoleRepository.findRoleByName(roleName);
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(`Role '${roleName}' does not exist!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.roles.add(role);
|
||||||
|
await this.em.persistAndFlush(user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
throw new Error('Error creating user with role');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticateUser(username: string, password: string) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findUserByUsername(username);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new Error('Invalid password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Error during authentication');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
251
src/apps/_app/tests/auth.test.ts
Normal file
251
src/apps/_app/tests/auth.test.ts
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { buildApp } from '../../../app';
|
||||||
|
import { UserService } from '../services/UserService';
|
||||||
|
import { UserRoleType } from '@/constants/roles';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { describe, beforeAll, afterAll, it, expect } from '@jest/globals';
|
||||||
|
import { MikroORM, EntityManager, IDatabaseDriver, Connection } from '@mikro-orm/core';
|
||||||
|
|
||||||
|
// Extend FastifyInstance to include container
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
container: {
|
||||||
|
resolve: (name: string) => any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Authentication System Tests', () => {
|
||||||
|
let appInstance: FastifyInstance;
|
||||||
|
let userService: UserService;
|
||||||
|
let testUserToken: string;
|
||||||
|
let testAdminToken: string;
|
||||||
|
let orm: EntityManager<IDatabaseDriver<Connection>>;
|
||||||
|
|
||||||
|
const testUser = {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'TestPass123!',
|
||||||
|
email: 'test@example.com',
|
||||||
|
roleName: 'user' as UserRoleType
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAdmin = {
|
||||||
|
username: 'testadmin',
|
||||||
|
password: 'AdminPass123!',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
roleName: 'admin' as UserRoleType
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
appInstance = await buildApp();
|
||||||
|
userService = appInstance.container.resolve('userService');
|
||||||
|
orm = appInstance.orm.em;
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
await userService.createUserWithRole(
|
||||||
|
testUser.username,
|
||||||
|
testUser.password,
|
||||||
|
testUser.email,
|
||||||
|
testUser.roleName
|
||||||
|
);
|
||||||
|
|
||||||
|
await userService.createUserWithRole(
|
||||||
|
testAdmin.username,
|
||||||
|
testAdmin.password,
|
||||||
|
testAdmin.email,
|
||||||
|
testAdmin.roleName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Clean up test users using the entity manager
|
||||||
|
const em = orm.fork();
|
||||||
|
const user = await em.findOne('User', { username: testUser.username });
|
||||||
|
const admin = await em.findOne('User', { username: testAdmin.username });
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await em.removeAndFlush(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
await em.removeAndFlush(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appInstance.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Login Endpoint Tests', () => {
|
||||||
|
it('should successfully login a regular user', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: {
|
||||||
|
username: testUser.username,
|
||||||
|
password: testUser.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.message).toBe('Authentication successful');
|
||||||
|
expect(data.data.token).toBeDefined();
|
||||||
|
expect(data.data.user).toBeDefined();
|
||||||
|
expect(data.data.user.username).toBe(testUser.username);
|
||||||
|
expect(data.data.user.roles).toContain('user');
|
||||||
|
|
||||||
|
testUserToken = data.data.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully login an admin user', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: {
|
||||||
|
username: testAdmin.username,
|
||||||
|
password: testAdmin.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.data.user.roles).toContain('admin');
|
||||||
|
|
||||||
|
testAdminToken = data.data.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail login with incorrect password', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: {
|
||||||
|
username: testUser.username,
|
||||||
|
password: 'wrongpassword'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toBe('Invalid username or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail login with non-existent username', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: {
|
||||||
|
username: 'nonexistentuser',
|
||||||
|
password: 'anypassword'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
expect(data.message).toBe('Invalid username or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail login with missing credentials', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/auth/login',
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Token Validation Tests', () => {
|
||||||
|
it('should validate a valid token', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/validate',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${testUserToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
expect(data.data.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an invalid token', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/validate',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer invalid.token.here'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject an expired token', async () => {
|
||||||
|
// Create an expired token
|
||||||
|
const expiredToken = jwt.sign(
|
||||||
|
{ id: 1, username: testUser.username, roles: ['user'] },
|
||||||
|
process.env.JWT_SECRET as string,
|
||||||
|
{ expiresIn: '-1h' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/validate',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${expiredToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a request without token', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/auth/validate'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
const data = JSON.parse(response.payload);
|
||||||
|
expect(data.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Role-Based Access Tests', () => {
|
||||||
|
it('should allow admin to access admin-only endpoint', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/dashboard',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${testAdminToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny regular user access to admin-only endpoint', async () => {
|
||||||
|
const response = await appInstance.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/dashboard',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${testUserToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
66
src/apps/canvas-api/CanvasClient.ts
Normal file
66
src/apps/canvas-api/CanvasClient.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
|
export class CanvasClient {
|
||||||
|
private axiosInstance: AxiosInstance;
|
||||||
|
private accessToken: string;
|
||||||
|
|
||||||
|
constructor(accessToken: string = process.env.CANVAS_API_TOKEN || '') {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.axiosInstance = axios.create({
|
||||||
|
baseURL: 'https://your-canvas-domain.com/api/v1/',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<T> = await this.axiosInstance.get(endpoint, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(endpoint: string, data?: object): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<T> = await this.axiosInstance.post(endpoint, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(endpoint: string, data?: object): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<T> = await this.axiosInstance.put(endpoint, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(endpoint: string, data?: object): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse<T> = await this.axiosInstance.delete(endpoint, {
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: unknown): never {
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
if (axiosError.response) {
|
||||||
|
throw axiosError.response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('Network error or no response from server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
src/apps/canvas-api/canvasRouter.ts
Normal file
10
src/apps/canvas-api/canvasRouter.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
// import configRoutes from './routes/ConfigRoutes';
|
||||||
|
import dummyGradesRoutes from './routes/DummyGradesRoutes';
|
||||||
|
|
||||||
|
const canvasRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
// app.register(configRoutes, { prefix: '/' });
|
||||||
|
app.register(dummyGradesRoutes, { prefix: '/' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default canvasRoutes;
|
1
src/apps/canvas-api/config.ts
Normal file
1
src/apps/canvas-api/config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// Config variables for mews app
|
21
src/apps/canvas-api/http/controllers/ConfigController.ts
Normal file
21
src/apps/canvas-api/http/controllers/ConfigController.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { ConfigService } from '../../services/_ConfigService';
|
||||||
|
|
||||||
|
export class ConfigController {
|
||||||
|
private configService: ConfigService;
|
||||||
|
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
this.configService = configService;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
// TODO: remove userId and make a id/role checker for user + add tenant
|
||||||
|
const config = await this.configService.getConfig();
|
||||||
|
reply.send(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
reply.code(500).send({ message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { DummyGradesService } from '../../services/DummyGradesService';
|
||||||
|
import {
|
||||||
|
DummyGradeResponse,
|
||||||
|
DummyGradeCreateBody,
|
||||||
|
DummyGradeUpdateParams,
|
||||||
|
DummyGradeDeleteParams
|
||||||
|
} from '../../types/DummyGradeTypes';
|
||||||
|
|
||||||
|
export class DummyGradesController {
|
||||||
|
private dummyGradesService: DummyGradesService;
|
||||||
|
|
||||||
|
constructor(dummyGradesService: DummyGradesService) {
|
||||||
|
this.dummyGradesService = dummyGradesService;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDummyGrades(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await this.dummyGradesService.getAllDummyGrades();
|
||||||
|
reply.send(response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
reply.status(500).send({ message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDummyGrade(req: FastifyRequest<{ Body: DummyGradeCreateBody }>, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { body } = req;
|
||||||
|
const response = await this.dummyGradesService.addDummyGrade(body);
|
||||||
|
reply.send(response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
reply.status(500).send({ message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDummyGrade(req: FastifyRequest<{ Body: DummyGradeUpdateParams }>, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { body } = req;
|
||||||
|
const response = await this.dummyGradesService.updateDummyGrade(body);
|
||||||
|
reply.send(response);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
reply.status(500).send({ message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDummyGrade(req: FastifyRequest<{ Body: DummyGradeDeleteParams }>, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.dummyGradesService.deleteDummyGrade(req.body);
|
||||||
|
reply.send({ success: true, message: 'Dummy grade deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||||
|
reply.status(500).send({ message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
src/apps/canvas-api/http/views/bookingWidget1.html
Normal file
54
src/apps/canvas-api/http/views/bookingWidget1.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mews Booking Engine Example</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distributor-open {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://api.mews.com/distributor/distributor.min.js"></script>
|
||||||
|
<!-- Mews BookingEngine -->
|
||||||
|
<script>(function (m, e, w, s) {
|
||||||
|
c = m.createElement(e); c.onload = function () {
|
||||||
|
Mews.D.apply(null, s)
|
||||||
|
}; c.async = 1; c.src = w; t = m.getElementsByTagName(e)[0]; t.parentNode.insertBefore(c, t);
|
||||||
|
})
|
||||||
|
(document, 'script', 'https://app.mews-demo.com/distributor/distributor.min.js', [['ca05f29a-758a-4252-9dbe-ab3a00b6eb83'], 'https://app.mews-demo.com']);</script>
|
||||||
|
<!-- End Mews BookingEngine -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<button class="distributor-open">Book Now</button>
|
||||||
|
<button class="distributor">Book Now2</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
Mews.Distributor({
|
||||||
|
configurationIds: ['ca05f29a-758a-4252-9dbe-ab3a00b6eb83'],
|
||||||
|
openElements: '.distributor-open'
|
||||||
|
},
|
||||||
|
function (api) {
|
||||||
|
// you can call API functions on a booking engine instance here
|
||||||
|
// set different start and end date
|
||||||
|
api.setStartDate(new Date(2022, 1, 1));
|
||||||
|
api.setEndDate(new Date(2022, 1, 3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
64
src/apps/canvas-api/http/views/bookingWidget2 copy 2.html
Normal file
64
src/apps/canvas-api/http/views/bookingWidget2 copy 2.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<!-- 1. Install booking engine loader script as close to the opening <head> tag as possible. -->
|
||||||
|
<script src="https://api.mews.com/distributor/distributor.min.js"></script>
|
||||||
|
<title>My page</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 2. Add form with date inputs. -->
|
||||||
|
<form id="date-form">
|
||||||
|
<label for="start">Start date:</label>
|
||||||
|
<input type="date" id="start" name="start" required />
|
||||||
|
<label for="end">End date:</label>
|
||||||
|
<input type="date" id="end" name="end" required />
|
||||||
|
<input type="submit" id="dates-submit" value="Loading..." disabled />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 3. Initialize Booking Engine Widget just before the closing </body> tag.
|
||||||
|
Mews.Distributor(
|
||||||
|
// Set Configuration ID of your booking engine.
|
||||||
|
{
|
||||||
|
configurationIds: ['Your booking engine Configuration ID'],
|
||||||
|
},
|
||||||
|
// Add callback which will enable Submit button and open the Booking Engine Widget upon button click.
|
||||||
|
function (api) {
|
||||||
|
// Listen on submit and when user submits, open booking engine with given dates.
|
||||||
|
const listenOnSubmit = () => {
|
||||||
|
// Find the form in DOM and listen on submit.
|
||||||
|
const form = document.getElementById('date-form');
|
||||||
|
|
||||||
|
form.addEventListener('submit', event => {
|
||||||
|
// Don't use the default submit button behavior. We want to handle it ourselves.
|
||||||
|
event.preventDefault();
|
||||||
|
// Get the dates from the date form.
|
||||||
|
const { start, end } = event.target.elements;
|
||||||
|
const [startYears, startMonths, startDays] = start.value.split('-');
|
||||||
|
const [endYears, endMonths, endDays] = end.value.split('-');
|
||||||
|
|
||||||
|
const startDate = new Date(startYears, startMonths - 1, startDays);
|
||||||
|
const endDate = new Date(endYears, endMonths - 1, endDays);
|
||||||
|
// Use the Booking Engine Widget Javascript API to set the dates in the widget and open it.
|
||||||
|
api.setStartDate(startDate);
|
||||||
|
api.setEndDate(endDate);
|
||||||
|
api.open();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
listenOnSubmit();
|
||||||
|
|
||||||
|
// Enable the submit button, because the Booking Engine Widget is ready to be used.
|
||||||
|
const enableSubmit = () => {
|
||||||
|
const submitButton = document.getElementById('dates-submit');
|
||||||
|
submitButton.value = 'Submit';
|
||||||
|
submitButton.disabled = false;
|
||||||
|
};
|
||||||
|
enableSubmit();
|
||||||
|
}
|
||||||
|
// 4. Note - this guide is written for the Production environment.
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
91
src/apps/canvas-api/http/views/bookingWidget2.html
Normal file
91
src/apps/canvas-api/http/views/bookingWidget2.html
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mews Booking Engine with Discount Code</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bookingContainer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://api.mews-demo.com/distributor/distributor.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<input type="text" id="discountCodeInput" placeholder="Enter Discount Code">
|
||||||
|
<button onclick="validateAndApplyCode()">Apply Discount</button>
|
||||||
|
|
||||||
|
<div id="bookingContainer">
|
||||||
|
<button class="distributor-open">Book Now</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Mews.Distributor({
|
||||||
|
// configurationIds: ['ca05f29a-758a-4252-9dbe-ab3a00b6eb83'],
|
||||||
|
// openElements: ''
|
||||||
|
// }, function (api) {
|
||||||
|
//
|
||||||
|
// }, {
|
||||||
|
// dataBaseUrl: 'https://api.mews-demo.com'
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
function validateAndApplyCode() {
|
||||||
|
const discountCode = document.getElementById('discountCodeInput').value;
|
||||||
|
// Call your backend API to validate the discount code
|
||||||
|
fetch('http://localhost:14000/api/v1/mews/voucher/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code: discountCode })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.isValid) {
|
||||||
|
// Mews.Distributor.setVoucherCode(discountCode);
|
||||||
|
// alert('Discount code applied successfully!');
|
||||||
|
Mews.Distributor({
|
||||||
|
configurationIds: ['ca05f29a-758a-4252-9dbe-ab3a00b6eb83'],
|
||||||
|
openElements: '.distributor-open'
|
||||||
|
}, function (api) {
|
||||||
|
|
||||||
|
api.open();
|
||||||
|
}, {
|
||||||
|
dataBaseUrl: 'https://api.mews-demo.com' // Points to Demo environment
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Invalid discount code!');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error validating discount code:', error);
|
||||||
|
alert('Failed to validate discount code.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
89
src/apps/canvas-api/http/views/demo_booking_widget.html
Normal file
89
src/apps/canvas-api/http/views/demo_booking_widget.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mews Booking Engine with Discount Code</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bookingContainer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="https://api.mews-demo.com/distributor/distributor.min.js"></script>
|
||||||
|
<!-- Mews BookingEngine -->
|
||||||
|
<!-- <script>(function(m,e,w,s){c=m.createElement(e);c.onload=function(){
|
||||||
|
Mews.D.apply(null,s)};c.async=1;c.src=w;t=m.getElementsByTagName(e)[0];t.parentNode.insertBefore(c,t);})
|
||||||
|
(document,'script','https://app.mews-demo.com/distributor/distributor.min.js',[['ca05f29a-758a-4252-9dbe-ab3a00b6eb83'],'https://app.mews-demo.com']);</script> -->
|
||||||
|
<!-- End Mews BookingEngine -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<input type="text" id="discountCodeInput" placeholder="Enter Discount Code">
|
||||||
|
<button onclick="validateAndApplyCode()">Apply Discount</button>
|
||||||
|
|
||||||
|
<div id="bookingContainer">
|
||||||
|
<button class="distributor-open">Book Now</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
Mews.Distributor({
|
||||||
|
configurationIds: ['53871e4e-f099-4a3b-9b86-b15901616683'],
|
||||||
|
openElements: '.distributor-open'
|
||||||
|
}, function (api) {
|
||||||
|
|
||||||
|
api.open();
|
||||||
|
}, {
|
||||||
|
dataBaseUrl: 'https://api.mews-demo.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateAndApplyCode() {
|
||||||
|
const discountCode = document.getElementById('discountCodeInput').value;
|
||||||
|
// Call your backend API to validate the discount code
|
||||||
|
fetch('http://localhost:14000/api/v1/mews/voucher/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ code: discountCode })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(data);
|
||||||
|
if (data.isValid) {
|
||||||
|
// Apply the voucher code through the Mews Distributor API
|
||||||
|
Mews.Distributor.setVoucherCode(discountCode);
|
||||||
|
alert('Discount code applied successfully!');
|
||||||
|
} else {
|
||||||
|
alert('Invalid discount code!');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error validating discount code:', error);
|
||||||
|
alert('Failed to validate discount code.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
23
src/apps/canvas-api/routes/ConfigRoutes.ts
Normal file
23
src/apps/canvas-api/routes/ConfigRoutes.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { CanvasClient } from '../CanvasClient';
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
import { ConfigController } from '../http/controllers/ConfigController';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
import { ConfigService } from '../services/_ConfigService';
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: split routes
|
||||||
|
const configRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const canvasClient = new CanvasClient();
|
||||||
|
|
||||||
|
const configService = new ConfigService(canvasClient);
|
||||||
|
const configController = new ConfigController(configService);
|
||||||
|
|
||||||
|
|
||||||
|
app.post('/config', configController.getConfig.bind(configController));
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default configRoutes;
|
24
src/apps/canvas-api/routes/DummyGradesRoutes.ts
Normal file
24
src/apps/canvas-api/routes/DummyGradesRoutes.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { DummyGradesController } from '../http/controllers/DummyGradesController';
|
||||||
|
import { DummyGradesService } from '../services/DummyGradesService';
|
||||||
|
import { CanvasClient } from '../CanvasClient';
|
||||||
|
|
||||||
|
const dummyGradesRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const canvasClient = new CanvasClient();
|
||||||
|
const dummyGradesService = new DummyGradesService(canvasClient);
|
||||||
|
const dummyGradesController = new DummyGradesController(dummyGradesService);
|
||||||
|
|
||||||
|
// Get all dummy grades
|
||||||
|
app.get('/', dummyGradesController.getAllDummyGrades.bind(dummyGradesController));
|
||||||
|
|
||||||
|
// Add a new dummy grade
|
||||||
|
app.post('/add', dummyGradesController.addDummyGrade.bind(dummyGradesController));
|
||||||
|
|
||||||
|
// Update a dummy grade
|
||||||
|
app.put('/update', dummyGradesController.updateDummyGrade.bind(dummyGradesController));
|
||||||
|
|
||||||
|
// Delete a dummy grade
|
||||||
|
app.delete('/delete', dummyGradesController.deleteDummyGrade.bind(dummyGradesController));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dummyGradesRoutes;
|
96
src/apps/canvas-api/services/DummyGradesService.ts
Normal file
96
src/apps/canvas-api/services/DummyGradesService.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { CanvasClient } from '../CanvasClient';
|
||||||
|
import {
|
||||||
|
DummyGradeResponse,
|
||||||
|
DummyGradeCreateBody,
|
||||||
|
DummyGradeUpdateParams,
|
||||||
|
DummyGradeDeleteParams
|
||||||
|
} from '@/apps/canvas-api/types/DummyGradeTypes';
|
||||||
|
|
||||||
|
export class DummyGradesService {
|
||||||
|
private canvas: CanvasClient;
|
||||||
|
|
||||||
|
constructor(canvasClient: CanvasClient) {
|
||||||
|
this.canvas = canvasClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllDummyGrades(): Promise<DummyGradeResponse> {
|
||||||
|
// Return mock data
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Dummy grades retrieved successfully',
|
||||||
|
data: {
|
||||||
|
grades: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
student_id: 101,
|
||||||
|
course_id: 1,
|
||||||
|
assignment_id: 1,
|
||||||
|
score: 85,
|
||||||
|
grade: 'B',
|
||||||
|
submitted_at: '2024-03-15T10:00:00Z',
|
||||||
|
graded_at: '2024-03-16T14:30:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
student_id: 102,
|
||||||
|
course_id: 1,
|
||||||
|
assignment_id: 1,
|
||||||
|
score: 92,
|
||||||
|
grade: 'A',
|
||||||
|
submitted_at: '2024-03-15T11:00:00Z',
|
||||||
|
graded_at: '2024-03-16T15:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addDummyGrade(payload: DummyGradeCreateBody): Promise<DummyGradeResponse> {
|
||||||
|
// Return mock success response
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Dummy grade added successfully',
|
||||||
|
data: {
|
||||||
|
id: 3,
|
||||||
|
student_id: payload.student_id,
|
||||||
|
course_id: payload.course_id,
|
||||||
|
assignment_id: payload.assignment_id,
|
||||||
|
score: payload.score,
|
||||||
|
grade: this.calculateGrade(payload.score),
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
graded_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDummyGrade(payload: DummyGradeUpdateParams): Promise<DummyGradeResponse> {
|
||||||
|
// Return mock success response
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Dummy grade updated successfully',
|
||||||
|
data: {
|
||||||
|
id: payload.id,
|
||||||
|
student_id: payload.student_id,
|
||||||
|
course_id: payload.course_id,
|
||||||
|
assignment_id: payload.assignment_id,
|
||||||
|
score: payload.score,
|
||||||
|
grade: this.calculateGrade(payload.score),
|
||||||
|
submitted_at: payload.submitted_at || new Date().toISOString(),
|
||||||
|
graded_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDummyGrade(payload: DummyGradeDeleteParams): Promise<void> {
|
||||||
|
// Return mock success response
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateGrade(score: number): string {
|
||||||
|
if (score >= 90) return 'A';
|
||||||
|
if (score >= 80) return 'B';
|
||||||
|
if (score >= 70) return 'C';
|
||||||
|
if (score >= 60) return 'D';
|
||||||
|
return 'F';
|
||||||
|
}
|
||||||
|
}
|
22
src/apps/canvas-api/services/_ConfigService.ts
Normal file
22
src/apps/canvas-api/services/_ConfigService.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { CanvasClient } from '../CanvasClient';
|
||||||
|
|
||||||
|
export class ConfigService {
|
||||||
|
private canvas: CanvasClient;
|
||||||
|
|
||||||
|
constructor(canvasClient: CanvasClient) {
|
||||||
|
this.canvas = canvasClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(): Promise<unknown> {
|
||||||
|
return this.canvas.get('configuration/get');
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateVoucher(payload: object): Promise<unknown> {
|
||||||
|
// const payload = {
|
||||||
|
// HotelId: hotelId,
|
||||||
|
// VoucherCode: voucherCode,
|
||||||
|
// };
|
||||||
|
|
||||||
|
return this.canvas.post('vouchers/validate', payload);
|
||||||
|
}
|
||||||
|
}
|
46
src/apps/canvas-api/types/DummyGradeTypes.ts
Normal file
46
src/apps/canvas-api/types/DummyGradeTypes.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface DummyGrade {
|
||||||
|
id: number;
|
||||||
|
student_id: number;
|
||||||
|
course_id: number;
|
||||||
|
assignment_id: number;
|
||||||
|
score: number;
|
||||||
|
grade: string;
|
||||||
|
submitted_at: string;
|
||||||
|
graded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DummyGradeResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
grades?: DummyGrade[];
|
||||||
|
id?: number;
|
||||||
|
student_id?: number;
|
||||||
|
course_id?: number;
|
||||||
|
assignment_id?: number;
|
||||||
|
score?: number;
|
||||||
|
grade?: string;
|
||||||
|
submitted_at?: string;
|
||||||
|
graded_at?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DummyGradeCreateBody {
|
||||||
|
student_id: number;
|
||||||
|
course_id: number;
|
||||||
|
assignment_id: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DummyGradeUpdateParams {
|
||||||
|
id: number;
|
||||||
|
student_id: number;
|
||||||
|
course_id: number;
|
||||||
|
assignment_id: number;
|
||||||
|
score: number;
|
||||||
|
submitted_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DummyGradeDeleteParams {
|
||||||
|
id: number;
|
||||||
|
}
|
7
src/constants/roles.ts
Normal file
7
src/constants/roles.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const PLATFORMROLES = {
|
||||||
|
Admin: 'admin',
|
||||||
|
Manager: 'manager',
|
||||||
|
User: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRoleType = (typeof PLATFORMROLES)[keyof typeof PLATFORMROLES];
|
43
src/database/migrations/Migration20240415134130.ts
Normal file
43
src/database/migrations/Migration20240415134130.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20240415134130 extends Migration {
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql('create table "app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);');
|
||||||
|
|
||||||
|
this.addSql('create table "tenant" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);');
|
||||||
|
|
||||||
|
this.addSql('create table "user" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "username" varchar(255) not null);');
|
||||||
|
|
||||||
|
this.addSql('create table "tenant_app" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "tenant_id" int not null, "app_id" int not null, "user_id" int not null);');
|
||||||
|
|
||||||
|
this.addSql('create table "apikey" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "key" varchar(255) not null, "user_id" int not null);');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant_app" add constraint "tenant_app_tenant_id_foreign" foreign key ("tenant_id") references "tenant" ("id") on update cascade;');
|
||||||
|
this.addSql('alter table "tenant_app" add constraint "tenant_app_app_id_foreign" foreign key ("app_id") references "app" ("id") on update cascade;');
|
||||||
|
this.addSql('alter table "tenant_app" add constraint "tenant_app_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
|
||||||
|
|
||||||
|
this.addSql('alter table "apikey" add constraint "apikey_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade;');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql('alter table "tenant_app" drop constraint "tenant_app_app_id_foreign";');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant_app" drop constraint "tenant_app_tenant_id_foreign";');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant_app" drop constraint "tenant_app_user_id_foreign";');
|
||||||
|
|
||||||
|
this.addSql('alter table "apikey" drop constraint "apikey_user_id_foreign";');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "app" cascade;');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "tenant" cascade;');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "user" cascade;');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "tenant_app" cascade;');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "apikey" cascade;');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
26
src/database/migrations/Migration20240415211308.ts
Normal file
26
src/database/migrations/Migration20240415211308.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20240415211308 extends Migration {
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql('create table "user_role" ("id" serial primary key, "created_at" timestamptz not null, "updated_at" timestamptz not null, "name" varchar(255) not null);');
|
||||||
|
|
||||||
|
this.addSql('create table "user_roles" ("user_id" int not null, "user_role_id" int not null, constraint "user_roles_pkey" primary key ("user_id", "user_role_id"));');
|
||||||
|
|
||||||
|
this.addSql('alter table "user_roles" add constraint "user_roles_user_id_foreign" foreign key ("user_id") references "user" ("id") on update cascade on delete cascade;');
|
||||||
|
this.addSql('alter table "user_roles" add constraint "user_roles_user_role_id_foreign" foreign key ("user_role_id") references "user_role" ("id") on update cascade on delete cascade;');
|
||||||
|
|
||||||
|
this.addSql('alter table "user" add column "password" varchar(255) not null, add column "email" varchar(255) not null;');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql('alter table "user_roles" drop constraint "user_roles_user_role_id_foreign";');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "user_role" cascade;');
|
||||||
|
|
||||||
|
this.addSql('drop table if exists "user_roles" cascade;');
|
||||||
|
|
||||||
|
this.addSql('alter table "user" drop column "password", drop column "email";');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
45
src/database/migrations/Migration20240415223053.ts
Normal file
45
src/database/migrations/Migration20240415223053.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20240415223053 extends Migration {
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql('alter table "app" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "app" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "tenant" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
|
||||||
|
this.addSql('alter table "user" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "user" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant_app" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "tenant_app" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
|
||||||
|
this.addSql('alter table "apikey" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "apikey" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
|
||||||
|
this.addSql('alter table "user_role" alter column "created_at" type timestamptz using ("created_at"::timestamptz);');
|
||||||
|
this.addSql('alter table "user_role" alter column "updated_at" type timestamptz using ("updated_at"::timestamptz);');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql('alter table "app" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "app" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "tenant" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
|
||||||
|
this.addSql('alter table "user" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "user" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
|
||||||
|
this.addSql('alter table "tenant_app" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "tenant_app" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
|
||||||
|
this.addSql('alter table "apikey" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "apikey" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
|
||||||
|
this.addSql('alter table "user_role" alter column "created_at" type jsonb using ("created_at"::jsonb);');
|
||||||
|
this.addSql('alter table "user_role" alter column "updated_at" type jsonb using ("updated_at"::jsonb);');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
9
src/database/migrations/Migration20240415225116.ts
Normal file
9
src/database/migrations/Migration20240415225116.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20240415225116 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
'alter table "user" add constraint "user_username_unique" unique ("username");'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
11
src/database/scripts/migrate.ts
Normal file
11
src/database/scripts/migrate.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import mikroOrmConfig from '../../../mikro-orm.config';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const orm = await MikroORM.init(mikroOrmConfig);
|
||||||
|
const migrator = orm.getMigrator();
|
||||||
|
|
||||||
|
await migrator.up();
|
||||||
|
await orm.close(true);
|
||||||
|
})();
|
26
src/database/seeds/RoleSeed.ts
Normal file
26
src/database/seeds/RoleSeed.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { UserRole } from '@/apps/_app/entities/user/UserRole';
|
||||||
|
import { PLATFORMROLES } from '@/constants/roles';
|
||||||
|
|
||||||
|
export class RoleSeed {
|
||||||
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
console.log('Seeding roles...');
|
||||||
|
|
||||||
|
const roles = Object.values(PLATFORMROLES);
|
||||||
|
|
||||||
|
for (const roleName of roles) {
|
||||||
|
const existingRole = await this.em.findOne(UserRole, { name: roleName });
|
||||||
|
|
||||||
|
if (!existingRole) {
|
||||||
|
const role = new UserRole();
|
||||||
|
role.name = roleName;
|
||||||
|
await this.em.persistAndFlush(role);
|
||||||
|
console.log(`Created role: ${roleName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Role seeding completed!');
|
||||||
|
}
|
||||||
|
}
|
53
src/database/seeds/UserSeed.ts
Normal file
53
src/database/seeds/UserSeed.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { User } from '@/apps/_app/entities/user/_User';
|
||||||
|
import { UserRole } from '@/apps/_app/entities/user/UserRole';
|
||||||
|
import { PLATFORMROLES, UserRoleType } from '@/constants/roles';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
interface SeedUser {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: UserRoleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserSeed {
|
||||||
|
private readonly seedUsers: SeedUser[] = [
|
||||||
|
{
|
||||||
|
username: process.env.DEFAULT_ADMIN_USERNAME || 'liquidrinu',
|
||||||
|
email: process.env.DEFAULT_ADMIN_EMAIL || 'liquidrinu@gmail.com',
|
||||||
|
password: process.env.DEFAULT_ADMIN_PASSWORD || 'admin123',
|
||||||
|
role: PLATFORMROLES.Admin as UserRoleType
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
console.log('Seeding users...');
|
||||||
|
|
||||||
|
for (const seedUser of this.seedUsers) {
|
||||||
|
const existingUser = await this.em.findOne(User, { username: seedUser.username });
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
const user = new User();
|
||||||
|
user.username = seedUser.username;
|
||||||
|
user.email = seedUser.email;
|
||||||
|
user.password = await bcrypt.hash(seedUser.password, 12);
|
||||||
|
|
||||||
|
const role = await this.em.findOne(UserRole, { name: seedUser.role });
|
||||||
|
if (!role) {
|
||||||
|
throw new Error(`Role ${seedUser.role} not found. Please run RoleSeed first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.roles.add(role);
|
||||||
|
await this.em.persistAndFlush(user);
|
||||||
|
console.log(`Created user: ${seedUser.username} with role ${seedUser.role}`);
|
||||||
|
} else {
|
||||||
|
console.log(`User ${seedUser.username} already exists, skipping...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('User seeding completed!');
|
||||||
|
}
|
||||||
|
}
|
29
src/database/seeds/index.ts
Normal file
29
src/database/seeds/index.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { UserSeed } from './UserSeed';
|
||||||
|
import { RoleSeed } from './RoleSeed';
|
||||||
|
|
||||||
|
export class DatabaseSeeder {
|
||||||
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
console.log('Starting database seeding...');
|
||||||
|
|
||||||
|
const fork = this.em.fork();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Seed roles first since users depend on them
|
||||||
|
const roleSeeder = new RoleSeed(fork);
|
||||||
|
await roleSeeder.run();
|
||||||
|
|
||||||
|
// Then seed users
|
||||||
|
const userSeeder = new UserSeed(fork);
|
||||||
|
await userSeeder.run();
|
||||||
|
|
||||||
|
await fork.flush();
|
||||||
|
console.log('Database seeding completed!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during seeding:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
src/database/seeds/run-seed.ts
Normal file
23
src/database/seeds/run-seed.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import { DatabaseSeeder } from './index';
|
||||||
|
import config from '../../../mikro-orm.config';
|
||||||
|
|
||||||
|
async function runSeeder() {
|
||||||
|
console.log('Initializing database connection...');
|
||||||
|
const orm = await MikroORM.init(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = new DatabaseSeeder(orm.em);
|
||||||
|
await seeder.run();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during seeding:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await orm.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runSeeder().catch(error => {
|
||||||
|
console.error('Fatal error during seeding:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
24
src/database/test-connection.ts
Normal file
24
src/database/test-connection.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import config from '../../mikro-orm.config';
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
console.log('Initializing database connection...');
|
||||||
|
const orm = await MikroORM.init(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Connection successful!');
|
||||||
|
console.log('Database name:', orm.config.get('dbName'));
|
||||||
|
console.log('Host:', orm.config.get('host'));
|
||||||
|
console.log('Port:', orm.config.get('port'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection failed:', error);
|
||||||
|
} finally {
|
||||||
|
await orm.close();
|
||||||
|
console.log('Connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testConnection().catch(error => {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
32
src/middleware/Auth.ts
Normal file
32
src/middleware/Auth.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { UserRoleType } from '@/constants/roles';
|
||||||
|
|
||||||
|
interface UserPayload extends jwt.JwtPayload {
|
||||||
|
roles: UserRoleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = (requiredRoles: UserRoleType[]) => async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
try {
|
||||||
|
const token = request.headers.authorization?.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ error: 'Authentication failed: No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as UserPayload;
|
||||||
|
|
||||||
|
const hasRequiredRole = requiredRoles.some((role) => decoded.roles && decoded.roles.includes(role));
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
return reply.code(403).send({ error: 'Unauthorized: Insufficient role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = decoded;
|
||||||
|
} catch (error) {
|
||||||
|
const typedError = error as Error;
|
||||||
|
return reply.code(401).send({ error: 'Authentication failed', info: typedError.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default auth;
|
33
src/plugins/Crypto.ts
Normal file
33
src/plugins/Crypto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// import fp from 'fastify-plugin';
|
||||||
|
// import crypto from 'crypto';
|
||||||
|
// import { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
|
// const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY as string; // Ensure this is 32 bytes for AES-256
|
||||||
|
// const IV_LENGTH = 16; // AES block size
|
||||||
|
|
||||||
|
// const cryptoPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
||||||
|
// fastify.decorate('encrypt', (text: string): string => {
|
||||||
|
// const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
// const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
|
||||||
|
// let encrypted = cipher.update(text);
|
||||||
|
// encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||||
|
// return iv.toString('hex') + ':' + encrypted.toString('hex');
|
||||||
|
// });
|
||||||
|
|
||||||
|
// fastify.decorate('decrypt', (text: string): string => {
|
||||||
|
// const textParts = text.split(':');
|
||||||
|
// const iv = Buffer.from(textParts.shift()!, 'hex');
|
||||||
|
// const encryptedText = Buffer.from(textParts.join(':'), 'hex');
|
||||||
|
// const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
|
||||||
|
// let decrypted = decipher.update(encryptedText);
|
||||||
|
// decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
// return decrypted.toString();
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default fp(cryptoPlugin, {
|
||||||
|
// name: 'cryptoPlugin',
|
||||||
|
// decorators: {
|
||||||
|
// fastify: ['encrypt', 'decrypt'],
|
||||||
|
// },
|
||||||
|
// });
|
20
src/plugins/JWT.ts
Normal file
20
src/plugins/JWT.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// import { FastifyPluginAsync } from 'fastify';
|
||||||
|
// import fJwt, { FastifyJWTOptions } from '@fastify/jwt';
|
||||||
|
|
||||||
|
// const jwtPlugin: FastifyPluginAsync<FastifyJWTOptions> = async (fastify) => {
|
||||||
|
// await fastify.register(fJwt, {
|
||||||
|
// secret: process.env.JWT_SECRET || 'your-secret-here',
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const authenticateDecorator: FastifyPluginAsync = async (fastify) => {
|
||||||
|
// fastify.decorate('authenticate', async (request, reply) => {
|
||||||
|
// try {
|
||||||
|
// await request.jwtVerify();
|
||||||
|
// } catch (err) {
|
||||||
|
// reply.send(err);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export { jwtPlugin, authenticateDecorator };
|
16
src/plugins/README.md
Normal file
16
src/plugins/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Plugins Folder
|
||||||
|
|
||||||
|
Plugins define behavior that is common to all the routes in your
|
||||||
|
application. Authentication, caching, templates, and all the other cross
|
||||||
|
cutting concerns should be handled by plugins placed in this folder.
|
||||||
|
|
||||||
|
Files in this folder are typically defined through the
|
||||||
|
[`fastify-plugin`](https://github.com/fastify/fastify-plugin) module,
|
||||||
|
making them non-encapsulated. They can define decorators and set hooks
|
||||||
|
that will then be used in the rest of your application.
|
||||||
|
|
||||||
|
Check out:
|
||||||
|
|
||||||
|
* [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/)
|
||||||
|
* [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/).
|
||||||
|
* [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/).
|
56
src/plugins/clients/piggy/piggyRequest.ts
Normal file
56
src/plugins/clients/piggy/piggyRequest.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import axios, { AxiosRequestConfig, Method, AxiosError } from 'axios';
|
||||||
|
import { HttpMethod } from '../../../plugins/shared/types';
|
||||||
|
|
||||||
|
interface ApiRequestOptions {
|
||||||
|
baseURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
apikey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiRequestPlugin: FastifyPluginAsync<ApiRequestOptions> = async (fastify, options) => {
|
||||||
|
const { baseURL } = options;
|
||||||
|
|
||||||
|
fastify.decorate(
|
||||||
|
'makeApiRequest',
|
||||||
|
async (method: HttpMethod, url: string, apiKey: ApiKey, body?: Record<string, unknown>) => {
|
||||||
|
const config: AxiosRequestConfig = {
|
||||||
|
method: method as Method,
|
||||||
|
url: baseURL + url,
|
||||||
|
data: body,
|
||||||
|
headers: { Authorization: `Bearer ${apiKey.apikey}` },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(config);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosError = err as AxiosError;
|
||||||
|
if (axiosError.response) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
statusCode: axiosError.response.status,
|
||||||
|
message: axiosError.response.data,
|
||||||
|
};
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
statusCode: 504,
|
||||||
|
message: 'No response from server',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
error: true,
|
||||||
|
statusCode: 500,
|
||||||
|
message: axiosError.message || 'Server error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fp(apiRequestPlugin);
|
11
src/plugins/sensible.ts
Normal file
11
src/plugins/sensible.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import sensible, { SensibleOptions } from '@fastify/sensible';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This plugins adds some utilities to handle http errors
|
||||||
|
*
|
||||||
|
* @see https://github.com/fastify/fastify-sensible
|
||||||
|
*/
|
||||||
|
export default fp<SensibleOptions>(async (fastify) => {
|
||||||
|
fastify.register(sensible);
|
||||||
|
});
|
9
src/plugins/shared/types.ts
Normal file
9
src/plugins/shared/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export enum HttpMethod {
|
||||||
|
GET = 'GET',
|
||||||
|
POST = 'POST',
|
||||||
|
PUT = 'PUT',
|
||||||
|
PATCH = 'PATCH',
|
||||||
|
DELETE = 'DELETE',
|
||||||
|
HEAD = 'HEAD',
|
||||||
|
OPTIONS = 'OPTIONS',
|
||||||
|
}
|
20
src/plugins/support.ts
Normal file
20
src/plugins/support.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import fp from 'fastify-plugin'
|
||||||
|
|
||||||
|
export interface SupportPluginOptions {
|
||||||
|
// Specify Support plugin options here
|
||||||
|
}
|
||||||
|
|
||||||
|
// The use of fastify-plugin is required to be able
|
||||||
|
// to export the decorators to the outer scope
|
||||||
|
export default fp<SupportPluginOptions>(async (fastify) => {
|
||||||
|
fastify.decorate('someSupport', function () {
|
||||||
|
return 'hugs'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// When using .decorate you have to specify added properties for Typescript
|
||||||
|
declare module 'fastify' {
|
||||||
|
export interface FastifyInstance {
|
||||||
|
someSupport(): string;
|
||||||
|
}
|
||||||
|
}
|
31
src/router.ts
Normal file
31
src/router.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import userRoutes from './apps/_app/routes/UserRoutes';
|
||||||
|
import canvasRoutes from './apps/canvas-api/canvasRouter';
|
||||||
|
|
||||||
|
const routesPlugin: FastifyPluginAsync = async (app) => {
|
||||||
|
try {
|
||||||
|
///////////////////////////////////////
|
||||||
|
// TODO: Define routes in separate list
|
||||||
|
await app.register(userRoutes, { prefix: '/app/users' });
|
||||||
|
await app.register(canvasRoutes, { prefix: '/canvas-api' });
|
||||||
|
|
||||||
|
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (!reply.sent) {
|
||||||
|
// Ensure it's not already sent
|
||||||
|
reply.code(500).send({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
reply.code(404).send({ message: 'Route not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: catch-all routes here or not?
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error, 'Failed to register routes');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default routesPlugin;
|
12
src/shared/exceptions/BadRequest.ts
Normal file
12
src/shared/exceptions/BadRequest.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class BadRequest extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.BadRequest;
|
||||||
|
constructor(msg?: string) {
|
||||||
|
super(msg);
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, BadRequest.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadRequest;
|
15
src/shared/exceptions/Conflict.ts
Normal file
15
src/shared/exceptions/Conflict.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class Conflict extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.Conflict;
|
||||||
|
public error: unknown;
|
||||||
|
//todo do this for then rest of the exceptions aswell
|
||||||
|
constructor(msg: object) {
|
||||||
|
super(JSON.stringify(msg));
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, Conflict.prototype);
|
||||||
|
this.error = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Conflict;
|
12
src/shared/exceptions/EntityNotFound.ts
Normal file
12
src/shared/exceptions/EntityNotFound.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class EntityNotFound extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.NotFound;
|
||||||
|
constructor(msg?: string) {
|
||||||
|
super(msg);
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, EntityNotFound.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EntityNotFound;
|
11
src/shared/exceptions/ErrorResponse.ts
Normal file
11
src/shared/exceptions/ErrorResponse.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class ErrorResponse extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.BadRequest;
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
Object.setPrototypeOf(this, ErrorResponse.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorResponse;
|
12
src/shared/exceptions/Forbidden.ts
Normal file
12
src/shared/exceptions/Forbidden.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class Forbidden extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.Forbidden;
|
||||||
|
constructor(msg: string = 'Forbidden') {
|
||||||
|
super(msg);
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, Forbidden.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Forbidden;
|
12
src/shared/exceptions/Ok.ts
Normal file
12
src/shared/exceptions/Ok.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class OK extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.Ok;
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, OK.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OK;
|
15
src/shared/exceptions/TooManyRequests.ts
Normal file
15
src/shared/exceptions/TooManyRequests.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
// class TooManyRequests extends Error {
|
||||||
|
// public httpErrorStatusCode = HttpStatusCode.TooManyRequests;
|
||||||
|
// constructor(msg: string, customErrorCode?: HttpStatusCode) {
|
||||||
|
// super(msg);
|
||||||
|
|
||||||
|
// this.httpErrorStatusCode = this.httpErrorStatusCode ?? customErrorCode;
|
||||||
|
|
||||||
|
// // Set the prototype explicitly.
|
||||||
|
// Object.setPrototypeOf(this, TooManyRequests.prototype);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default TooManyRequests;
|
15
src/shared/exceptions/UnableToProceed.ts
Normal file
15
src/shared/exceptions/UnableToProceed.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class UnableToProceed extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.BadRequest;
|
||||||
|
constructor(msg: string, customErrorCode?: typeof HttpStatusCode) {
|
||||||
|
super(msg);
|
||||||
|
|
||||||
|
this.httpErrorStatusCode = this.httpErrorStatusCode ?? customErrorCode;
|
||||||
|
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, UnableToProceed.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnableToProceed;
|
12
src/shared/exceptions/Unauthorized.ts
Normal file
12
src/shared/exceptions/Unauthorized.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
class Unauthorized extends Error {
|
||||||
|
public httpErrorStatusCode = HttpStatusCode.Unauthorized;
|
||||||
|
constructor(msg: string = 'Unauthorized') {
|
||||||
|
super(msg);
|
||||||
|
// Set the prototype explicitly.
|
||||||
|
Object.setPrototypeOf(this, Unauthorized.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Unauthorized;
|
12
src/shared/exceptions/ValidationError.ts
Normal file
12
src/shared/exceptions/ValidationError.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// import { HttpStatusCode } from 'axios';
|
||||||
|
|
||||||
|
// class ValidationError extends Error {
|
||||||
|
// public httpErrorStatusCode = HttpStatusCode.BadRequest;
|
||||||
|
// constructor(msg: string) {
|
||||||
|
// super(msg);
|
||||||
|
// // Set the prototype explicitly.
|
||||||
|
// Object.setPrototypeOf(this, ValidationError.prototype);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export default ValidationError;
|
39
src/shared/interfaces/HttpResponse.ts
Normal file
39
src/shared/interfaces/HttpResponse.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import ErrorResponse from '../exceptions/ErrorResponse';
|
||||||
|
|
||||||
|
export class HttpResponse<responseObject, requestBody = unknown> {
|
||||||
|
public url: string;
|
||||||
|
public response: responseObject;
|
||||||
|
public code: number;
|
||||||
|
public requestBody: requestBody | undefined
|
||||||
|
|
||||||
|
constructor(response: responseObject, code: number, url: string, requestBody?: requestBody) {
|
||||||
|
this.url = url;
|
||||||
|
this.response = response;
|
||||||
|
this.code = code;
|
||||||
|
this.requestBody = requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isErrorResponseCode() {
|
||||||
|
return this.code >= 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleErrorResponse() {
|
||||||
|
if (this.isErrorResponseCode()) {
|
||||||
|
throw new ErrorResponse(
|
||||||
|
`error connecting with external service, status code: ${
|
||||||
|
this.code
|
||||||
|
}, response: ${JSON.stringify(this.response)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleEmptyResponse() {
|
||||||
|
if (
|
||||||
|
this.response === null ||
|
||||||
|
this.response === undefined ||
|
||||||
|
this.response === ''
|
||||||
|
) {
|
||||||
|
throw new ErrorResponse('empty response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
src/shared/utils/ValidationError.ts
Normal file
9
src/shared/utils/ValidationError.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message: string, public details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, ValidationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
src/tests/setup.ts
Normal file
35
src/tests/setup.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import config from '../../mikro-orm.config';
|
||||||
|
import { DatabaseSeeder } from '../database/seeds';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config({ path: '.env.test' });
|
||||||
|
|
||||||
|
// Set default environment variables for testing
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.JWT_SECRET = 'test-secret-key';
|
||||||
|
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test_db';
|
||||||
|
|
||||||
|
let orm: MikroORM;
|
||||||
|
|
||||||
|
// Global setup
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Initialize the database connection
|
||||||
|
orm = await MikroORM.init(config);
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
const migrator = orm.getMigrator();
|
||||||
|
await migrator.up();
|
||||||
|
|
||||||
|
// Seed the database
|
||||||
|
const seeder = new DatabaseSeeder(orm.em);
|
||||||
|
await seeder.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global cleanup
|
||||||
|
afterAll(async () => {
|
||||||
|
if (orm) {
|
||||||
|
await orm.close();
|
||||||
|
}
|
||||||
|
});
|
37
src/types/fastify.types.d.ts
vendored
Normal file
37
src/types/fastify.types.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { EntityManager } from '@mikro-orm/core';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { SignOptions, VerifyOptions } from 'jsonwebtoken';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface AppOptions {
|
||||||
|
jwt: {
|
||||||
|
sign: (payload: object, options?: SignOptions) => string;
|
||||||
|
verify: (token: string, options?: VerifyOptions) => object;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyInstance {
|
||||||
|
user?: User;
|
||||||
|
|
||||||
|
orm: {
|
||||||
|
em: EntityManager;
|
||||||
|
};
|
||||||
|
jwt: {
|
||||||
|
sign: (payload: object, options?: SignOptions) => string;
|
||||||
|
verify: (token: string, options?: VerifyOptions) => object;
|
||||||
|
};
|
||||||
|
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyRequest {
|
||||||
|
diContainer: DIContainer;
|
||||||
|
em: EntityManager;
|
||||||
|
jwtVerify: () => Promise<void>;
|
||||||
|
// controllers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
userDetails?: jwt.JwtPayload & { roles: string[] };
|
||||||
|
}
|
||||||
|
}
|
140
test/integration/user.test.ts
Normal file
140
test/integration/user.test.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { test } from 'tap';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import mikroOrmConfig from '../../mikro-orm.config';
|
||||||
|
import { User } from '@/apps/_app/entities/user/_User';
|
||||||
|
|
||||||
|
test('User API', async (t) => {
|
||||||
|
// Setup
|
||||||
|
const fastify = Fastify();
|
||||||
|
await fastify.register(app);
|
||||||
|
const orm = await MikroORM.init(mikroOrmConfig);
|
||||||
|
const em = orm.em.fork();
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = async () => {
|
||||||
|
await fastify.close();
|
||||||
|
await orm.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
t.test('POST /api/v1/app/users', async (t) => {
|
||||||
|
const response = await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users',
|
||||||
|
payload: {
|
||||||
|
username: 'apitestuser',
|
||||||
|
password: 'testpass123',
|
||||||
|
email: 'api@example.com',
|
||||||
|
roleName: 'user'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.equal(response.statusCode, 201, 'should return 201 status code');
|
||||||
|
const body = JSON.parse(response.payload);
|
||||||
|
t.ok(body.id, 'should return user with id');
|
||||||
|
t.equal(body.username, 'apitestuser', 'should create user with correct username');
|
||||||
|
|
||||||
|
// Cleanup this test's user
|
||||||
|
const user = await em.findOne(User, { username: 'apitestuser' });
|
||||||
|
if (user) await em.removeAndFlush(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('POST /api/v1/app/users/login', async (t) => {
|
||||||
|
// First create a user
|
||||||
|
await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users',
|
||||||
|
payload: {
|
||||||
|
username: 'logintestuser',
|
||||||
|
password: 'testpass123',
|
||||||
|
email: 'login@example.com',
|
||||||
|
roleName: 'user'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users/login',
|
||||||
|
payload: {
|
||||||
|
username: 'logintestuser',
|
||||||
|
password: 'testpass123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.equal(response.statusCode, 200, 'should return 200 status code');
|
||||||
|
const body = JSON.parse(response.payload);
|
||||||
|
t.ok(body.token, 'should return JWT token');
|
||||||
|
t.equal(body.message, 'Authentication successful', 'should return success message');
|
||||||
|
|
||||||
|
// Test with invalid credentials
|
||||||
|
const invalidResponse = await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users/login',
|
||||||
|
payload: {
|
||||||
|
username: 'logintestuser',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.equal(invalidResponse.statusCode, 401, 'should return 401 for invalid credentials');
|
||||||
|
|
||||||
|
// Cleanup this test's user
|
||||||
|
const user = await em.findOne(User, { username: 'logintestuser' });
|
||||||
|
if (user) await em.removeAndFlush(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('GET /api/v1/app/users (requires auth)', async (t) => {
|
||||||
|
// First create and login as a user
|
||||||
|
await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users',
|
||||||
|
payload: {
|
||||||
|
username: 'authtestuser',
|
||||||
|
password: 'testpass123',
|
||||||
|
email: 'auth@example.com',
|
||||||
|
roleName: 'user'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginResponse = await fastify.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/app/users/login',
|
||||||
|
payload: {
|
||||||
|
username: 'authtestuser',
|
||||||
|
password: 'testpass123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = JSON.parse(loginResponse.payload);
|
||||||
|
|
||||||
|
const response = await fastify.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/app/users',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.equal(response.statusCode, 200, 'should return 200 with valid token');
|
||||||
|
const body = JSON.parse(response.payload);
|
||||||
|
t.ok(Array.isArray(body), 'should return array of users');
|
||||||
|
|
||||||
|
// Test without token
|
||||||
|
const noAuthResponse = await fastify.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/app/users'
|
||||||
|
});
|
||||||
|
|
||||||
|
t.equal(noAuthResponse.statusCode, 401, 'should return 401 without token');
|
||||||
|
|
||||||
|
// Cleanup this test's user
|
||||||
|
const user = await em.findOne(User, { username: 'authtestuser' });
|
||||||
|
if (user) await em.removeAndFlush(user);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// Ensure cleanup happens even if tests fail
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
});
|
76
test/services/UserService.test.ts
Normal file
76
test/services/UserService.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { test } from 'tap';
|
||||||
|
import { MikroORM } from '@mikro-orm/core';
|
||||||
|
import { UserService } from '@/apps/_app/services/UserService';
|
||||||
|
import { UserRepository } from '@/apps/_app/repositories/UserRepository';
|
||||||
|
import { UserRoleRepository } from '@/apps/_app/repositories/UserRoleRepository';
|
||||||
|
import { User } from '@/apps/_app/entities/user/_User';
|
||||||
|
import { UserRole } from '@/apps/_app/entities/user/UserRole';
|
||||||
|
import mikroOrmConfig from '../../mikro-orm.config';
|
||||||
|
|
||||||
|
test('UserService', async (t) => {
|
||||||
|
// Setup
|
||||||
|
const orm = await MikroORM.init(mikroOrmConfig);
|
||||||
|
const em = orm.em.fork();
|
||||||
|
|
||||||
|
const userRepository = new UserRepository(em, User);
|
||||||
|
const userRoleRepository = new UserRoleRepository(em, UserRole);
|
||||||
|
const userService = new UserService(em, userRepository, userRoleRepository);
|
||||||
|
|
||||||
|
t.test('createUserWithRole', async (t) => {
|
||||||
|
const username = 'testuser';
|
||||||
|
const password = 'testpass123';
|
||||||
|
const email = 'test@example.com';
|
||||||
|
const roleName = 'user';
|
||||||
|
|
||||||
|
const user = await userService.createUserWithRole(username, password, email, roleName);
|
||||||
|
|
||||||
|
t.ok(user, 'should create a user');
|
||||||
|
t.equal(user.username, username, 'should set correct username');
|
||||||
|
t.equal(user.email, email, 'should set correct email');
|
||||||
|
t.ok(user.roles.length > 0, 'should assign roles');
|
||||||
|
t.equal(user.roles[0].name, roleName, 'should assign correct role');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await em.removeAndFlush(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('authenticateUser', async (t) => {
|
||||||
|
const username = 'authuser';
|
||||||
|
const password = 'authpass123';
|
||||||
|
const email = 'auth@example.com';
|
||||||
|
const roleName = 'user';
|
||||||
|
|
||||||
|
// Create a user first
|
||||||
|
const user = await userService.createUserWithRole(username, password, email, roleName);
|
||||||
|
|
||||||
|
t.test('with valid credentials', async (t) => {
|
||||||
|
const authenticatedUser = await userService.authenticateUser(username, password);
|
||||||
|
t.ok(authenticatedUser, 'should authenticate successfully');
|
||||||
|
t.equal(authenticatedUser.username, username, 'should return correct user');
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('with invalid password', async (t) => {
|
||||||
|
try {
|
||||||
|
await userService.authenticateUser(username, 'wrongpassword');
|
||||||
|
t.fail('should throw error for invalid password');
|
||||||
|
} catch (error) {
|
||||||
|
t.ok(error, 'should throw error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test('with non-existent user', async (t) => {
|
||||||
|
try {
|
||||||
|
await userService.authenticateUser('nonexistent', password);
|
||||||
|
t.fail('should throw error for non-existent user');
|
||||||
|
} catch (error) {
|
||||||
|
t.ok(error, 'should throw error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await em.removeAndFlush(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await orm.close();
|
||||||
|
});
|
13
test/tsconfig.json
Normal file
13
test/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "..",
|
||||||
|
"baseUrl": "..",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["node", "tap"]
|
||||||
|
},
|
||||||
|
"include": ["../src/**/*.ts", "./**/*.ts"],
|
||||||
|
"exclude": ["../node_modules", "../dist"]
|
||||||
|
}
|
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": false,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"newLine": "lf",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user