Compare commits

..

29 Commits

Author SHA1 Message Date
M1000fr
7b43387a0c refactor: Update ping response message format to include the current date and time in French locale 2024-12-13 18:00:55 +01:00
M1000fr
8f51c04cad refactor: Update AUTH_USERNAME_FIELD in .env.example 2024-12-13 13:36:15 +01:00
M1000fr
41a9bfad98 refactor: remove providerId from User 2024-12-13 13:32:12 +01:00
M1000fr
e52c9acf2d feat: remove unused packages, config 2024-12-12 22:06:35 +01:00
M1000fr
bc4dcc26ef refactor: Update user creation logic in UserService 2024-12-12 16:57:28 +01:00
M1000fr
28e42b2248 feat: Add error handling for invalid token in JwtAuthGuard 2024-12-11 18:51:48 +01:00
M1000fr
80ce7d7f16 feat: switch to JWKS verification 2024-12-11 00:41:53 +01:00
M1000fr
0ae3da74a0 refactor: Update Node.js base image in Dockerfile 2024-12-10 14:41:30 +01:00
M1000fr
006fe8cb74 feat: Add Swagger API documentation to AuthController and ClassController 2024-12-10 14:33:43 +01:00
M1000fr
161b01d8cb chrore: Integrate Class CRUD & Service
refactor: user CRUD & Service
2024-12-10 14:19:27 +01:00
M1000fr
238b01f5f3 feat: Add Class module and controller
Add a new Class module and controller to handle CRUD operations for classes. This includes creating a new file for the Class controller, as well as creating the Class module and updating the app.module.ts file to include the Class module.
2024-12-10 11:40:04 +01:00
3d131193b6 ref: format prettier 2024-12-06 01:15:40 +01:00
38e491dc13 refactor: Remove unnecessary comment in Oauth2Strategy constructor 2024-12-06 01:03:30 +01:00
dd486229c4 feat: implement oauth2 2024-12-06 01:00:32 +01:00
M1000fr
76093fb5e8 refactor: Update base Node.js image to Alpine version in Dockerfile 2024-12-05 13:29:49 +01:00
M1000fr
63176d1863 feat: change Role decorator to accept multiples roles 2024-12-05 12:15:28 +01:00
M1000fr
d9cc0db0d2 ref: just add another test route 2024-12-05 00:12:51 +01:00
M1000fr
8245f4ddfc ref: use nestjs-prisma for error handling
- Use GlobalFilters created by nestjs-prisma
- Rename user/users routes
2024-12-03 17:35:11 +01:00
M1000fr
c2028d9309 ref: remove unused params 2024-12-02 19:24:30 +01:00
M1000fr
2e22e0bb4d refactor: Update JWTStrategy to extend PassportStrategy with "jwt" name 2024-12-02 16:57:20 +01:00
M1000fr
a6d24cee9d ref: format with prettier 2024-12-02 16:55:44 +01:00
M1000fr
ccf496a5d9 ref: remove majs on folder 2024-12-02 16:41:13 +01:00
M1000fr
ab51873493 feat: Implement refresh jwt token 2024-12-02 16:39:18 +01:00
M1000fr
6521da705b ref: move dto folder to Dto 2024-12-02 15:39:14 +01:00
M1000fr
f3c5673c75 feat: add swagger 2024-12-02 15:25:36 +01:00
M1000fr
b07b6082e4 refactor: Update Prisma schema to include createdAt field in User and RoomMessage models 2024-12-02 14:38:01 +01:00
M1000fr
3301b365f9 feat: Add Docker configuration files
Add .dockerignore, Dockerfile, and docker-compose.yml files to configure Docker for the project.
2024-12-02 14:37:58 +01:00
M1000fr
1988673f80 feat: add ConfigService with Env validator
- add Role decorator
2024-12-02 13:57:47 +01:00
M1000fr
a95ed47302 refactor: Update Prisma schema to include UserMessage, Class, Room, and RoomMessage models 2024-11-29 00:26:37 +01:00
55 changed files with 1645 additions and 719 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
*.log
*.md
.git

View File

@ -1 +1,6 @@
JWT_SECRET= DATABASE_URL="mysql://USER:PASS@IP:PORT/DB"
AUTH_JWKS_URI=
AUTH_USERNAME_FIELD="name"
PORT=3000

View File

@ -2,5 +2,9 @@
"singleQuote": false, "singleQuote": false,
"trailingComma": "all", "trailingComma": "all",
"useTabs": true, "useTabs": true,
"tabWidth": 4 "tabWidth": 4,
"semi": true,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
} }

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# Use the official Node.js image as the base image
FROM node:20
# Set the working directory inside the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install the application dependencies
RUN yarn install
# Copy the rest of the application files
COPY . .
# Generate Prisma client
RUN yarn prisma generate
# Build the NestJS application
RUN yarn build
# Expose the application port
EXPOSE 3000
# Command to run the application
CMD ["node", "dist/main"]

5
docker-compose.yml Normal file
View File

@ -0,0 +1,5 @@
services:
api:
image: toogether/api
ports:
- "3000:3000"

View File

@ -5,39 +5,47 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"" "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"",
"migrate:dev": "npx prisma migrate dev",
"migrate:dev:create": "npx prisma migrate dev --create-only",
"migrate:deploy": "npx prisma migrate deploy",
"prisma:generate": "npx prisma generate",
"prisma:studio": "npx prisma studio",
"prisma:seed": "npx prisma db seed"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.6", "@nestjs/mapped-types": "^2.0.6",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.11", "@nestjs/platform-express": "^10.4.11",
"@prisma/client": "5.22.0", "@nestjs/platform-socket.io": "^10.4.12",
"@nestjs/swagger": "^8.0.7",
"@nestjs/websockets": "^10.4.12",
"@prisma/client": "^6.0.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "joi": "^17.13.3",
"passport-discord": "^0.1.4", "jsonwebtoken": "^9.0.2",
"passport-jwt": "^4.0.1", "jwks-rsa": "^3.1.0",
"prisma": "^5.22.0", "nestjs-prisma": "^0.23.0",
"prisma": "^6.0.1",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/passport-discord": "^0.1.14",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",

View File

@ -1,21 +1,98 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "sqlite" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id
username String? username String
isAdmin Boolean @default(false) role Role @default(STUDENT)
password String? createdAt DateTime @default(now())
Class Class[]
SentMessages UserMessage[] @relation("SentMessages")
ReceivedMessages UserMessage[] @relation("ReceivedMessages")
RoomSurveyAnswerUser RoomSurveyAnswerUser[]
}
model UserMessage {
id Int @id @default(autoincrement())
content String
sender User @relation(fields: [sender_user_Id], references: [id], name: "SentMessages")
sender_user_Id String
receiver User @relation(fields: [receiver_user_Id], references: [id], name: "ReceivedMessages")
receiver_user_Id String
createdAt DateTime @default(now())
}
model Class {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
ClassRoom Room[]
Students User[]
}
model Room {
id String @id @default(cuid())
name String
Class Class[]
Messages RoomMessage[]
Documents RoomDocument[]
Surveys RoomSurvey[]
}
model RoomMessage {
id Int @id @default(autoincrement())
content String
Room Room @relation(fields: [roomId], references: [id])
roomId String
}
model RoomDocument {
id Int @id @default(autoincrement())
content String
Room Room @relation(fields: [roomId], references: [id])
roomId String
}
model RoomSurvey {
id Int @id @default(autoincrement())
content String
Room Room @relation(fields: [roomId], references: [id])
roomId String
createdAt DateTime @default(now())
endAt DateTime?
Answers RoomSurveyAnswer[]
}
model RoomSurveyAnswer {
id Int @id @default(autoincrement())
content String
Survey RoomSurvey @relation(fields: [surveyId], references: [id])
surveyId Int
isRight Boolean
Users RoomSurveyAnswerUser[]
}
model RoomSurveyAnswerUser {
id Int @id @default(autoincrement())
User User @relation(fields: [userId], references: [id])
userId String
Answer RoomSurveyAnswer @relation(fields: [answerId], references: [id])
answerId Int
}
enum Role {
STUDENT
ADMIN
} }

View File

@ -0,0 +1,9 @@
import { ApiResponseNoStatusOptions } from "@nestjs/swagger";
export const UnauthorizedResponse = {
description: "Unauthorized",
example: {
message: "Unauthorized",
statusCode: 401,
},
} as ApiResponseNoStatusOptions;

15
src/app.controller.ts Normal file
View File

@ -0,0 +1,15 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiBearerAuth } from "@nestjs/swagger";
import { JwtAuthGuard } from "./modules/auth/guards/jwt.guard";
@Controller()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class AppController {
@Get("ping")
pong() {
return {
message: new Date().toLocaleString("fr"),
};
}
}

View File

@ -1,24 +1,28 @@
import env from "@Config/env";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt"; import { envValidation } from "@Validations/env.validation";
import { AppService } from "./app.service"; import { AuthModule } from "@Modules/auth/auth.module";
import { UserModule } from "./user/user.module"; import { UserModule } from "@Modules/user/user.module";
import { PrismaModule } from "nestjs-prisma";
import { AuthModule } from './auth/auth.module'; import { AppController } from "./app.controller";
import { ClassModule } from "./modules/class/class.module";
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [env],
validationSchema: envValidation,
}), }),
JwtModule.register({ PrismaModule.forRoot({
global: true, isGlobal: true,
secret: process.env.JWT_SECRET,
}), }),
UserModule, UserModule,
AuthModule, AuthModule,
ClassModule,
], ],
providers: [AppService], controllers: [AppController]
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,10 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello() {
return {
message: "Hello World!",
};
}
}

View File

@ -1,13 +0,0 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class DiscordAuthGuard extends AuthGuard("discord") {
handleRequest(err, user, info, context) {
if (err || !user) {
const errorMessage = info?.message || "Authentication failed";
throw new UnauthorizedException(errorMessage);
}
return user;
}
}

View File

@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}

View File

@ -1,33 +0,0 @@
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-discord";
@Injectable()
export class DiscordStrategy extends PassportStrategy(Strategy, "discord") {
constructor(private jwtService: JwtService) {
super({
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: process.env.DISCORD_CALLBACK_URL,
scope: ["identify", "email"]
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: Function,
) {
const jwtPayload = {
id: profile.id,
username: profile.username,
email: profile.email,
};
const jwt = this.jwtService.sign(jwtPayload);
done(null, { jwt });
}
}

View File

@ -1,19 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Profile } from "passport-discord";
import { Strategy, ExtractJwt } from "passport-jwt";
@Injectable()
export class JWTStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExipration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(profile: Profile): Promise<any> {
return profile;
}
}

View File

@ -1,37 +0,0 @@
import { Controller, Get, Query, Req, Res, UseGuards } from "@nestjs/common";
import { URLSearchParams } from "node:url";
import { JwtAuthGuard } from "./Guards/jwt.guard";
import { DiscordAuthGuard } from "./Guards/discord.guard";
@Controller("auth")
export class AuthController {
@Get("providers")
Providers() {
const discordOauth2Params = new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
response_type: "code",
redirect_uri: process.env.DISCORD_CALLBACK_URL,
scope: "identify email",
});
return {
discord: {
url: `https://discord.com/oauth2/authorize?${discordOauth2Params.toString()}`,
},
};
}
@Get("discord/callback")
@UseGuards(DiscordAuthGuard)
CallbackDiscord(@Req() req, @Res() res) {
const { user } = req;
res.send(user);
}
@Get("profile")
@UseGuards(JwtAuthGuard)
Profile(@Req() req) {
return req.user;
}
}

View File

@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { DiscordStrategy } from "./Strategy/discord.strategy";
import { JWTStrategy } from "./Strategy/jwt.strategy";
@Module({
controllers: [AuthController],
providers: [AuthService, DiscordStrategy, JWTStrategy],
})
export class AuthModule {}

View File

@ -1,4 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class AuthService {}

6
src/config/env.ts Normal file
View File

@ -0,0 +1,6 @@
export default () => ({
auth: {
jwksURL: process.env.AUTH_JWKS_URI,
usernameField: process.env.AUTH_USERNAME_FIELD
},
});

View File

@ -0,0 +1,3 @@
export interface JwtPayload {
id: string;
}

6
src/interfaces/oauth2.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Oauth2Profile {
id?: string;
sub?: string;
email: string;
}

View File

@ -1,6 +1,12 @@
import { NestFactory } from "@nestjs/core"; import {
ClassSerializerInterceptor,
HttpStatus,
ValidationPipe,
} from "@nestjs/common";
import { HttpAdapterHost, NestFactory, Reflector } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { PrismaClientExceptionFilter } from "nestjs-prisma";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -9,8 +15,45 @@ async function bootstrap() {
origin: "*", origin: "*",
}); });
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(
new PrismaClientExceptionFilter(httpAdapter, {
P2000: {
errorMessage: "A required field is missing",
statusCode: HttpStatus.BAD_REQUEST,
},
P2002: {
errorMessage:
"A ressource with the same unique fields already exists",
statusCode: HttpStatus.CONFLICT,
},
P2025: {
errorMessage: "The requested ressource does not exist",
statusCode: HttpStatus.NOT_FOUND,
},
}),
);
app.useGlobalPipes(new ValidationPipe()); app.useGlobalPipes(new ValidationPipe());
app.useGlobalInterceptors(
new ClassSerializerInterceptor(app.get(Reflector), {
excludeExtraneousValues: true,
}),
);
const config = new DocumentBuilder()
.setTitle("Toogether API")
.addBearerAuth()
.build();
const documentFactory = () => SwaggerModule.createDocument(app, config);
SwaggerModule.setup("documentation", app, documentFactory, {
swaggerOptions: {
tryItOutEnabled: true,
persistAuthorization: true,
},
});
await app.listen(process.env.PORT ?? 3000, "0.0.0.0"); await app.listen(process.env.PORT ?? 3000, "0.0.0.0");
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { AuthService } from "./auth.service";
@Module({
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,49 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as jwt from "jsonwebtoken";
import JwksRsa, * as jwksRsa from "jwks-rsa";
@Injectable()
export class AuthService {
private jwksClient: JwksRsa.JwksClient;
constructor(configService: ConfigService) {
this.jwksClient = jwksRsa({
jwksUri: configService.get<string>("AUTH_JWKS_URI"),
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 10,
});
}
async getSigningKey(kid: string): Promise<string> {
const key = await this.jwksClient.getSigningKey(kid);
return key.getPublicKey();
}
decodeJwt(token: string) {
return jwt.decode(token, { complete: true });
}
verifyJwt(token: string, key: string) {
return jwt.verify(token, key, {
algorithms: ["RS256"],
});
}
async checkToken(token: string): Promise<jwt.JwtPayload> {
const decodedHeader = this.decodeJwt(token);
const kid = decodedHeader?.header?.kid;
if (!kid) throw "Token kid not found";
const key = await this.getSigningKey(kid);
const jwtPayload = this.verifyJwt(token, key);
if (typeof jwtPayload == "string")
throw new UnauthorizedException("Invalid token");
return jwtPayload;
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
import { $Enums } from "@prisma/client";
export const Roles = (roles: $Enums.Role[]) => SetMetadata("roles", roles);

View File

@ -0,0 +1,55 @@
import { UserService } from "@/modules/user/user.service";
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { AuthService } from "../auth.service";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException("No token provided");
}
try {
const jwtPayload = await this.authService.checkToken(token);
let user = await this.userService.findOrCreate({
id: jwtPayload.sub.toString(),
username:
jwtPayload[this.configService.get("auth.usernameField")],
});
request.user = user;
return true;
} catch (err) {
throw new UnauthorizedException(`Invalid token: ${err.message}`);
}
}
private extractTokenFromHeader(request: any): string | null {
const authHeader = request.headers["authorization"];
if (!authHeader) return null;
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
}
}

View File

@ -0,0 +1,45 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const RolesHandler = this.reflector.get<string[]>(
"roles",
context.getHandler(),
);
const RolesClass = this.reflector.get<string[]>(
"roles",
context.getClass(),
);
if (!RolesHandler && !RolesClass) return true;
const request = context.switchToHttp().getRequest() as Request;
const user = request.user;
if (!user) throw new ForbiddenException("User not authenticated");
const hasRoleHandler =
RolesHandler?.some((role) => user.role?.includes(role)) ??
false,
hasRoleClass =
RolesClass?.some((role) => user.role?.includes(role)) ?? false;
if (hasRoleHandler) return true;
else if (hasRoleClass) return true;
else
throw new UnauthorizedException(
`User doesn't have the right role, expected: ${RolesHandler ?? RolesClass}`,
);
}
}

View File

@ -0,0 +1,40 @@
import { ApiResponseNoStatusOptions } from "@nestjs/swagger";
import { ClassEntity } from "../entities/class.entity";
export const ClassResponse = {
type: ClassEntity,
examples: {
example: {
summary: "A class",
value: {
id: "1",
name: "Sigyn",
createdAt: new Date(),
} as ClassEntity,
},
},
} as ApiResponseNoStatusOptions;
export const ClassesResponse = {
type: ClassEntity,
isArray: true,
examples: {
example: {
summary: "A list of classes",
value: [
{ id: "1", name: "Sigyn", createdAt: new Date() },
{ id: "2", name: "Loki", createdAt: new Date() },
] as ClassEntity[],
},
},
} as ApiResponseNoStatusOptions;
export const ClassCountResponse = {
description: "The class count",
examples: {
example: {
summary: "A count of classes",
value: { count: 2 },
},
},
} as ApiResponseNoStatusOptions;

View File

@ -0,0 +1,102 @@
import { UnauthorizedResponse } from "@/ApiResponses/UnauthorizedResponse";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import {
ClassCountResponse,
ClassesResponse,
ClassResponse,
} from "./ApiResponses/ClassResponse";
import { ClassService } from "./class.service";
import { CreateClassDto } from "./dto/create-class.dto";
import { UpdateClassDto } from "./dto/update-class.dto";
import { Roles } from "@/modules/auth/decorators/roles.decorator";
import { JwtAuthGuard } from "@/modules/auth/guards/jwt.guard";
import { RolesGuard } from "@/modules/auth/guards/role.guard";
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { ClassEntity } from "./entities/class.entity";
@Controller("class")
@UseGuards(RolesGuard)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Roles(["ADMIN"])
@ApiUnauthorizedResponse(UnauthorizedResponse)
export class ClassController {
constructor(private readonly classService: ClassService) {}
@Post()
@ApiOkResponse(ClassResponse)
@ApiOperation({ summary: "Create a new class" })
async create(@Body() createClassDto: CreateClassDto) {
return await this.classService.create(createClassDto).then((class_) => {
return new ClassEntity(class_);
});
}
@Get()
@ApiOkResponse(ClassesResponse)
@ApiOperation({ summary: "Get all classes" })
async findAll() {
return await this.classService
.findAll({})
.then((classes) =>
classes.map((class_) => new ClassEntity(class_)),
);
}
@Get(":id")
@ApiOkResponse(ClassResponse)
@ApiOperation({ summary: "Get a class by id" })
async findOne(@Param("id") id: string) {
return await this.classService
.findOne(id)
.then((class_) => new ClassEntity(class_));
}
@Patch(":id")
@ApiOkResponse(ClassResponse)
@ApiOperation({ summary: "Update a class by id" })
async update(
@Param("id") id: string,
@Body() updateClassDto: UpdateClassDto,
) {
return await this.classService
.update(id, updateClassDto)
.then((class_) => new ClassEntity(class_));
}
@Delete(":id")
@ApiOkResponse(ClassResponse)
@ApiOperation({ summary: "Remove a class by id" })
async remove(@Param("id") id: string) {
return await this.classService
.remove(id)
.then((class_) => new ClassEntity(class_));
}
@Delete()
@ApiOkResponse(ClassCountResponse)
@ApiOperation({ summary: "Remove multiple classes by ids" })
@ApiQuery({ name: "ids", required: true, type: [String] })
async bulkRemove(@Query("ids") ids: string | string[]) {
if (typeof ids === "string") ids = [ids];
return await this.classService.bulkRemove(ids);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ClassService } from "./class.service";
import { ClassController } from "./class.controller";
import { UserModule } from "../user/user.module";
@Module({
imports: [UserModule],
controllers: [ClassController],
providers: [ClassService],
})
export class ClassModule {}

View File

@ -0,0 +1,57 @@
import { Injectable } from "@nestjs/common";
import { CreateClassDto } from "./dto/create-class.dto";
import { UpdateClassDto } from "./dto/update-class.dto";
import { PrismaService } from "nestjs-prisma";
import { Prisma } from "@prisma/client";
@Injectable()
export class ClassService {
constructor(private readonly prisma: PrismaService) {}
async create(createClassDto: CreateClassDto) {
return await this.prisma.class.create({ data: createClassDto });
}
async findAll({
skip,
take,
cursor,
where,
orderBy,
}: {
skip?: number;
take?: number;
cursor?: Prisma.ClassWhereUniqueInput;
where?: Prisma.ClassWhereInput;
orderBy?: Record<string, unknown>;
}) {
return await this.prisma.class.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async findOne(id: string) {
return await this.prisma.class.findUniqueOrThrow({ where: { id } });
}
async update(id: string, updateClassDto: UpdateClassDto) {
return await this.prisma.class.update({
where: { id },
data: updateClassDto,
});
}
async remove(id: string) {
return await this.prisma.class.delete({ where: { id } });
}
async bulkRemove(ids: string[]) {
return await this.prisma.class.deleteMany({
where: { id: { in: ids } },
});
}
}

View File

@ -0,0 +1,8 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString } from "class-validator";
export class CreateClassDto {
@IsString()
@ApiProperty()
name: string;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateClassDto } from "./create-class.dto";
export class UpdateClassDto extends PartialType(CreateClassDto) {}

View File

@ -0,0 +1,21 @@
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
import { Expose } from "class-transformer";
@ApiSchema({ name: "Class" })
export class ClassEntity {
@Expose()
@ApiProperty()
id: string;
@Expose()
@ApiProperty()
name: string;
@Expose()
@ApiProperty()
createdAt: Date;
constructor(partial: Partial<ClassEntity>) {
Object.assign(this, partial);
}
}

View File

@ -0,0 +1,41 @@
import { ApiResponseNoStatusOptions } from "@nestjs/swagger";
import { UserEntity } from "../entities/user.entity";
export const UserResponse = {
type: UserEntity,
description: "The user has been successfully found.",
examples: {
example: {
summary: "A user example",
value: {
id: "1",
role: "ADMIN",
username: "admin",
} as UserEntity,
},
},
} as ApiResponseNoStatusOptions;
export const UsersResponse = {
type: UserEntity,
isArray: true,
examples: {
example: {
summary: "A list of users",
value: [
{ id: "1", role: "ADMIN", username: "admin" },
{ id: "2", role: "STUDENT", username: "student" },
] as UserEntity[],
},
},
} as ApiResponseNoStatusOptions;
export const UserCountResponse = {
description: "The users count",
examples: {
example: {
summary: "A count of users",
value: { count: 2 },
},
},
} as ApiResponseNoStatusOptions;

View File

@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { Role } from "@prisma/client";
import { IsString } from "class-validator";
export class CreateUserDTO {
@IsString()
@ApiProperty()
id: string;
@IsString()
@ApiProperty()
username: string;
@IsString()
@ApiProperty()
role: Role;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDTO } from "./create-user.dto";
export class UpdateUserDTO extends PartialType(CreateUserDTO) {}

View File

@ -0,0 +1,22 @@
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
import { $Enums } from "@prisma/client";
import { Expose } from "class-transformer";
@ApiSchema({ name: "User" })
export class UserEntity {
@Expose()
@ApiProperty()
id: string;
@Expose()
@ApiProperty()
username: string;
@Expose()
@ApiProperty()
role: $Enums.Role;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}

View File

@ -0,0 +1,94 @@
import { UnauthorizedResponse } from "@/ApiResponses/UnauthorizedResponse";
import { Roles } from "@/modules/auth/decorators/roles.decorator";
import { JwtAuthGuard } from "@/modules/auth/guards/jwt.guard";
import { RolesGuard } from "@/modules/auth/guards/role.guard";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Query,
UseGuards
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiUnauthorizedResponse
} from "@nestjs/swagger";
import {
UserCountResponse,
UserResponse,
UsersResponse,
} from "./ApiResponses/UserReponse";
import { UpdateUserDTO } from "./dto/update-user.dto";
import { UserEntity } from "./entities/user.entity";
import { UserService } from "./user.service";
@Controller("user")
@UseGuards(RolesGuard)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Roles(["ADMIN"])
@ApiUnauthorizedResponse(UnauthorizedResponse)
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOkResponse(UsersResponse)
@ApiOperation({ summary: "Get all users" })
async findAll(): Promise<UserEntity[]> {
return await this.userService
.findAll()
.then((users) => users.map((user) => new UserEntity(user)));
}
@Get(":id")
@ApiOkResponse(UserResponse)
@ApiOperation({ summary: "Get user by id" })
async findOne(@Param("id") id: string): Promise<UserEntity> {
return this.userService
.findOne(id)
.then((user) => new UserEntity(user));
}
@Patch(":id")
@ApiOkResponse(UserResponse)
@ApiOperation({ summary: "Update user by id" })
async update(
@Param("id") id: string,
@Body() updateUserDto: UpdateUserDTO,
): Promise<UserEntity> {
return this.userService
.update(id, updateUserDto)
.then((user) => new UserEntity(user));
}
@Delete(":id")
@ApiOkResponse(UserResponse)
@ApiOperation({ summary: "Delete user by id" })
@ApiParam({
name: "id",
type: String,
description: "The user id",
example: "1",
})
async remove(@Param("id") id: string): Promise<UserEntity> {
return this.userService.remove(id).then((user) => new UserEntity(user));
}
@Delete()
@ApiOkResponse(UserCountResponse)
@ApiOperation({ summary: "Delete users by ids" })
@ApiQuery({ name: "ids", required: true, type: [String] })
bulkRemove(@Query("ids") ids: string | string[]): Promise<{
count: number;
}> {
if (typeof ids === "string") ids = [ids];
return this.userService.bulkRemove(ids);
}
}

View File

@ -0,0 +1,62 @@
import {
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
WsResponse,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service";
import { UserService } from "./user.service";
import { ConfigService } from "@nestjs/config";
@WebSocketGateway()
export class UserGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly configService: ConfigService,
) {}
@WebSocketServer() io: Server;
public clients: Socket[] = [];
async handleConnection(client: Socket) {
const token = client.handshake.headers.authorization;
try {
var jwtPayload = await this.authService.checkToken(token);
if (!jwtPayload) throw "Invalid token";
} catch (error) {
client.emit("auth", error);
return client.disconnect();
}
const user = await this.userService.findOrCreate({
id: jwtPayload.sub.toString(),
username: jwtPayload[this.configService.get("auth.usernameField")],
});
if (!user) {
client.emit("auth", "User not found");
return client.disconnect();
}
client.request.user = user;
this.clients.push(client);
}
handleDisconnect(client: Socket) {
this.clients = this.clients.filter((c) => c.id !== client.id);
}
@SubscribeMessage("message")
sendMessage(_client: Socket, message: string): WsResponse<unknown> {
this.io.emit("message", message);
return null;
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { PrismaService } from "nestjs-prisma";
import { AuthService } from "@Modules/auth/auth.service";
import { UserController } from "./user.controller";
import { UserGateway } from "./user.gateway";
import { UserService } from "./user.service";
@Module({
providers: [UserService, AuthService, PrismaService, UserGateway],
controllers: [UserController],
exports: [UserService, AuthService],
})
export class UserModule {}

View File

@ -0,0 +1,96 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "nestjs-prisma";
import { CreateUserDTO } from "./dto/create-user.dto";
import { UpdateUserDTO } from "./dto/update-user.dto";
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async findAll({
skip,
take,
cursor,
where,
orderBy,
}: {
skip?: number;
take?: number;
cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput;
orderBy?: Record<string, unknown>;
} = {}) {
return await this.prisma.user.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async findOne(id: string) {
return await this.prisma.user.findUniqueOrThrow({
where: { id },
});
}
async findOrCreate({ id, username }: { id: string; username: string }) {
let user = await this.prisma.user.findFirst({
where: {
id,
username,
},
});
if (!user) {
const isFirstUser = (await this.prisma.user.count()) === 0;
user = await this.prisma.user.create({
data: {
id,
username,
role: isFirstUser ? "ADMIN" : "STUDENT",
},
});
}
return user;
}
async create(createUserDto: CreateUserDTO) {
return await this.prisma.user.create({
data: {
id: createUserDto.id,
username: createUserDto.username,
},
});
}
async update(id: string, updateUserInput: UpdateUserDTO) {
return await this.prisma.user.update({
where: { id },
data: {
username: updateUserInput.username,
},
});
}
async remove(id: string) {
return await this.prisma.user.delete({
where: { id },
});
}
async bulkRemove(ids: string[]) {
return await this.prisma.user.deleteMany({
where: {
id: {
in: ids,
},
},
});
}
}

View File

@ -1,9 +0,0 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}

9
src/types/http.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import { UserEntity } from "@/modules/user/entities/user.entity";
import { User } from "@prisma/client";
import { IncomingMessage } from "http";
declare module "http" {
interface IncomingMessage {
user?: UserEntity;
}
}

View File

@ -1,9 +0,0 @@
import { IsBoolean, IsString } from "class-validator";
export class CreateUserInput {
@IsString()
username: string;
@IsBoolean()
isAdmin: boolean;
}

View File

@ -1,9 +0,0 @@
import { IsString } from "class-validator";
export class SetUserPasswordInput {
@IsString()
id: string;
@IsString()
password: string;
}

View File

@ -1,12 +0,0 @@
import { PartialType } from "@nestjs/mapped-types";
import { IsString } from "class-validator";
import { CreateUserInput } from "./create-user.input";
export class UpdateUserInput extends PartialType(CreateUserInput) {
@IsString()
id: string;
@IsString()
username: string;
}

View File

@ -1,34 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { UserService } from "./user.service";
import { JwtAuthGuard } from "src/auth/Guards/jwt.guard";
import { CreateUserInput } from "./dto/create-user.input";
@Controller("user")
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
create(@Body() createUserInput: CreateUserInput) {
return this.userService.create(createUserInput);
}
@Get()
findAll() {
return this.userService.findAll();
}
@Delete(":id")
removeUser(@Param("id") id: string) {
return this.userService.remove(id);
}
}

View File

@ -1,12 +0,0 @@
import { Exclude } from "class-transformer";
export class UserEntity {
id: string;
username: string;
isAdmin: boolean;
@Exclude()
password: string;
}

View File

@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { UserService } from "./user.service";
import { PrismaService } from "src/prisma/prisma.service";
import { UserController } from './user.controller';
@Module({
providers: [UserService, PrismaService],
controllers: [UserController]
})
export class UserModule {}

View File

@ -1,77 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserInput } from "./dto/create-user.input";
import { UpdateUserInput } from "./dto/update-user.input";
import { SetUserPasswordInput } from "./dto/setpassword-user.input";
import { UserEntity } from "./user.entity";
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async create(createUserInput: CreateUserInput) {
const user = await this.prisma.user.create({
data: {
username: createUserInput.username,
isAdmin: createUserInput.isAdmin,
},
});
return plainToClass(UserEntity, user);
}
async update(updateUserInput: UpdateUserInput) {
const user = await this.prisma.user.update({
where: { id: updateUserInput.id },
data: {
username: updateUserInput.username,
isAdmin: updateUserInput.isAdmin,
},
});
return plainToClass(UserEntity, user);
}
async setPassword(setUserPasswordInput: SetUserPasswordInput) {
const exist = await this.prisma.user.findUnique({
where: { id: setUserPasswordInput.id },
});
if (!exist) throw new NotFoundException("User not found");
const user = await this.prisma.user.update({
where: { id: setUserPasswordInput.id },
data: {
password: setUserPasswordInput.password,
},
});
return plainToClass(UserEntity, user);
}
async findAll() {
const users = await this.prisma.user.findMany();
return users.map((user) => plainToClass(UserEntity, user));
}
async findOne(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
});
return plainToClass(UserEntity, user);
}
async remove(id: string) {
const exist = await this.prisma.user.findUnique({ where: { id } });
if (!exist) throw new NotFoundException("User not found");
const user = await this.prisma.user.delete({
where: { id },
});
return plainToClass(UserEntity, user);
}
}

View File

@ -0,0 +1,10 @@
import * as Joi from "joi";
export const envValidation = Joi.object({
DATABASE_URL: Joi.string().required(),
AUTH_JWKS_URI: Joi.string().uri().required(),
AUTH_USERNAME_FIELD: Joi.string().required(),
PORT: Joi.number().optional(),
});

View File

@ -1,4 +1,10 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] "exclude": [
"node_modules",
"test",
"dist",
"**/*spec.ts",
"prisma"
]
} }

View File

@ -9,7 +9,15 @@
"target": "ES2021", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./src",
"typeRoots": ["./src/types/*.d.ts"],
"paths": {
"@/*": ["*"],
"@Modules/*": ["modules/*"],
"@Interfaces/*": ["interfaces/*"],
"@Config/*": ["config/*"],
"@Validations/*": ["validations/*"]
},
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": false, "strictNullChecks": false,

895
yarn.lock

File diff suppressed because it is too large Load Diff