first commit - backend init

This commit is contained in:
DarrenT~ 2025-04-29 07:51:17 +02:00
parent 3ba62a3609
commit 48a04969bc
79 changed files with 3304 additions and 0 deletions

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

@ -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

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"jsxSingleQuote": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 144,
"bracketSpacing": true
}

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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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;

@ -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();
}
}

@ -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;
}

@ -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);
}

@ -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;
}

@ -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;
}

@ -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);
}

@ -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);
}

@ -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);
}
}
}

@ -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'
});
}
}
}

@ -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 });
}
}

@ -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'] });
}
}

@ -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 });
}
}

@ -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;

@ -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;
}
}

@ -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');
}
}
}

@ -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);
});
});
});

@ -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');
}
}
}

@ -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;

@ -0,0 +1 @@
// Config variables for mews app

@ -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 });
}
}
}

@ -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>

@ -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>

@ -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>

@ -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>

@ -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;

@ -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;

@ -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';
}
}

@ -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);
}
}

@ -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

@ -0,0 +1,7 @@
export const PLATFORMROLES = {
Admin: 'admin',
Manager: 'manager',
User: 'user',
} as const;
export type UserRoleType = (typeof PLATFORMROLES)[keyof typeof PLATFORMROLES];

@ -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;');
}
}

@ -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";');
}
}

@ -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);');
}
}

@ -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");'
);
}
}

@ -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);
})();

@ -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!');
}
}

@ -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!');
}
}

@ -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;
}
}
}

@ -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);
});

@ -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

@ -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

@ -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

@ -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

@ -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/).

@ -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

@ -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);
});

@ -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

@ -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

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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;

@ -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');
}
}
}

@ -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

@ -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

@ -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[] };
}
}

@ -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();
}
});

@ -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

@ -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

@ -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"]
}