Compare commits

...

10 Commits

15 changed files with 170 additions and 79 deletions

View File

@ -1,16 +1,16 @@
{
"name": "@toogether/server",
"name": "@toogether/api",
"version": "0.0.1",
"private": true,
"license": "Apache-2.0",
"scripts": {
"build": "nest build",
"build:docker": "docker build --no-cache -t toogether/api .",
"build:docker": "docker build -t toogether/api .",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start:prod": "docker compose up --force-recreate -d",
"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')\"",
"migrate:dev": "npx prisma migrate dev",

View File

@ -10,7 +10,6 @@ datasource db {
model User {
id String @id
username String
role Role @default(STUDENT)
createdAt DateTime @default(now())
Class Class[]
@ -105,8 +104,3 @@ model RoomSurveyAnswerUser {
Answer RoomSurveyAnswer @relation(fields: [answerId], references: [id])
answerId Int
}
enum Role {
STUDENT
ADMIN
}

View File

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

View File

@ -7,12 +7,13 @@ import {
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { decode, JwtPayload, UserJwtPayload } from "jsonwebtoken";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const RolesHandler = this.reflector.get<string[]>(
"roles",
context.getHandler(),
@ -29,11 +30,21 @@ export class RolesGuard implements CanActivate {
if (!user) throw new ForbiddenException("User not authenticated");
const decodedToken = await this.decodeToken(
this.extractTokenFromHeader(request.headers.authorization),
);
// Check if the user has the right role
// On the handler level
const hasRoleHandler =
RolesHandler?.some((role) => user.role?.includes(role)) ??
false,
RolesHandler?.some((role) =>
decodedToken.realm_access.roles?.includes(role),
) ?? false,
// On the class level
hasRoleClass =
RolesClass?.some((role) => user.role?.includes(role)) ?? false;
RolesClass?.some((role) =>
decodedToken.realm_access.roles?.includes(role),
) ?? false;
if (hasRoleHandler) return true;
else if (hasRoleClass) return true;
@ -42,4 +53,20 @@ export class RolesGuard implements CanActivate {
`User doesn't have the right role, expected: ${RolesHandler ?? RolesClass}`,
);
}
async decodeToken(token: string) {
try {
return decode(token) as UserJwtPayload;
} catch (error) {
throw new UnauthorizedException("Invalid token");
}
}
extractTokenFromHeader(header: string) {
const token = header.split(" ")[1];
if (!token) throw new UnauthorizedException("Token not found");
return token;
}
}

View File

@ -39,7 +39,7 @@ import { ClassRoomEntity } from "./entities/room.entity";
@UseGuards(RolesGuard)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Roles(["ADMIN"])
@Roles(["admin"])
@ApiUnauthorizedResponse(UnauthorizedResponse)
export class ClassController {
constructor(private readonly classService: ClassService) { }

View File

@ -8,7 +8,7 @@ import * as moment from "moment";
@Injectable()
export class ClassService {
constructor(private readonly prisma: PrismaService) { }
constructor(private readonly prisma: PrismaService) {}
async create(createClassDto: CreateClassDto) {
return await this.prisma.class.create({ data: createClassDto });
@ -20,7 +20,7 @@ export class ClassService {
cursor,
where,
orderBy,
include
include,
}: {
skip?: number;
take?: number;
@ -35,7 +35,7 @@ export class ClassService {
cursor,
where,
orderBy,
include
include,
});
}
@ -68,12 +68,11 @@ export class ClassService {
include: { Students: true },
});
if (!Class)
throw new HttpException("Class not found", 404);
if (!Class) throw new HttpException("Class not found", 404);
const studentIdsToAdd = studentIds.filter(
(studentId) =>
!Class.Students.some((student) => student.id === studentId)
!Class.Students.some((student) => student.id === studentId),
);
if (studentIdsToAdd.length === 0) return Class;
@ -82,7 +81,9 @@ export class ClassService {
where: { id: classId },
data: {
Students: {
connect: studentIdsToAdd.map((studentId) => ({ id: studentId })),
connect: studentIdsToAdd.map((studentId) => ({
id: studentId,
})),
},
},
});
@ -97,18 +98,40 @@ export class ClassService {
async createRoom(classId: string, createRoomClassDto: CreateRoomClassDto) {
// Check if end time is greater than start time
const invalidTime = createRoomClassDto.times.find(
(time) => moment(time.start, "HH:mm").isAfter(moment(time.end, "HH:mm"))
const invalidTime = createRoomClassDto.times.find((time, i) =>
moment(time.start, "HH:mm").isAfter(moment(time.end, "HH:mm")),
);
if (invalidTime)
throw new HttpException("Invalid time", 400);
throw new HttpException(
`The end time must be greater than the start time, ${invalidTime.start} - ${invalidTime.end}`,
400,
);
// Check if date is in the past
const invalidDate = moment(createRoomClassDto.date).isBefore(moment());
const date = moment(createRoomClassDto.date);
if (invalidDate)
throw new HttpException("Invalid date", 400);
// Check if the date is before the current date
if (date.isBefore(moment().startOf("day")))
throw new HttpException(
"Can't create a room for a class that has passed",
400,
);
const parseTimes = createRoomClassDto.times.map((time) => ({
start: moment(date).set("hour", moment(time.start, "HH:mm").hour()),
end: moment(date).set("hour", moment(time.end, "HH:mm").hour()),
}));
const lastTimeEnd = parseTimes.reduce((prev, current) =>
moment(current.end).isAfter(moment(prev.end)) ? current : prev,
);
// Check if the last time end is before the current time
if (moment(lastTimeEnd.end).isBefore(moment().subtract(5, "minutes")))
throw new HttpException(
"Can't create a room for a class that has already ended",
400,
);
return await this.prisma.room.create({
include: { Times: true },
@ -132,10 +155,14 @@ export class ClassService {
}
async getRooms(classId: string) {
return await this.prisma.class.findUnique({
return await this.prisma.class
.findUnique({
where: { id: classId },
include: { ClassRoom: { include: { Times: true, Presentator: true } } },
}).then((class_) => class_.ClassRoom);
include: {
ClassRoom: { include: { Times: true, Presentator: true } },
},
})
.then((class_) => class_.ClassRoom);
}
async getUserClasses(userId: string) {

View File

@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt.guard';
import { RolesGuard } from '../auth/guards/role.guard';
import { isUserInClassGuard } from './guards/isUserInClass.guard';
import { MeService } from './me.service';
import { UserService } from '../user/user.service';
@Controller('@me')
@UseGuards(RolesGuard)
@ -13,7 +14,16 @@ import { MeService } from './me.service';
@ApiBearerAuth()
@ApiUnauthorizedResponse(UnauthorizedResponse)
export class MeController {
constructor(private readonly meService: MeService) { }
constructor(
private readonly meService: MeService,
private readonly userService: UserService,
) { }
@Get()
@ApiOkResponse({ description: 'Get my profile' })
async getMyProfile(@Req() req: Request) {
return await this.userService.getProfile(req.user.id);
}
@Get("/class")
@ApiOkResponse({ description: 'Get all classes' })

View File

@ -3,7 +3,9 @@ import { ClassService } from '../class/class.service';
@Injectable()
export class MeService {
constructor(private readonly classService: ClassService) { }
constructor(
private readonly classService: ClassService,
) { }
async getMyClasses(userId: string) {
return await this.classService.getUserClasses(userId);

View File

@ -11,6 +11,13 @@ export const UserResponse = {
id: "1",
role: "ADMIN",
username: "admin",
Class: [
{
id: "1",
name: "Class 1",
createdAt: new Date(),
},
],
} as UserEntity,
},
},
@ -23,8 +30,8 @@ export const UsersResponse = {
example: {
summary: "A list of users",
value: [
{ id: "1", role: "ADMIN", username: "admin" },
{ id: "2", role: "STUDENT", username: "student" },
{ id: "1", username: "admin" },
{ id: "2", username: "student" },
] as UserEntity[],
},
},

View File

@ -1,5 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { Role } from "@prisma/client";
import { IsString } from "class-validator";
export class CreateUserDTO {
@ -10,8 +9,4 @@ export class CreateUserDTO {
@IsString()
@ApiProperty()
username: string;
@IsString()
@ApiProperty()
role: Role;
}

View File

@ -1,5 +1,5 @@
import { ClassEntity } from "@/modules/class/entities/class.entity";
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
import { $Enums } from "@prisma/client";
import { Expose } from "class-transformer";
@ApiSchema({ name: "User" })
@ -14,7 +14,7 @@ export class UserEntity {
@Expose()
@ApiProperty()
role: $Enums.Role;
Class?: ClassEntity[];
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);

View File

@ -11,7 +11,7 @@ import {
Patch,
Query,
Req,
UseGuards
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
@ -19,8 +19,9 @@ import {
ApiOperation,
ApiParam,
ApiQuery,
ApiUnauthorizedResponse
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { Request } from "express";
import {
UserCountResponse,
UserResponse,
@ -29,13 +30,12 @@ import {
import { UpdateUserDTO } from "./dto/update-user.dto";
import { UserEntity } from "./entities/user.entity";
import { UserService } from "./user.service";
import { Request } from "express";
@Controller("user")
@UseGuards(RolesGuard)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Roles(["ADMIN"])
@Roles(["admin"])
@ApiUnauthorizedResponse(UnauthorizedResponse)
export class UserController {
constructor(private readonly userService: UserService) {}
@ -45,14 +45,21 @@ export class UserController {
@ApiOperation({ summary: "Get all users" })
async findAll(): Promise<UserEntity[]> {
return await this.userService
.findAll()
.findAll({
include: {
Class: true,
},
})
.then((users) => users.map((user) => new UserEntity(user)));
}
@Get(":id")
@ApiOkResponse(UserResponse)
@ApiOperation({ summary: "Get user by id" })
async findOne(@Param("id") id: string, @Req() req: Request): Promise<UserEntity> {
async findOne(
@Param("id") id: string,
@Req() req: Request,
): Promise<UserEntity> {
if (id === "@me") id = req.user.id;
return this.userService

View File

@ -7,7 +7,7 @@ import { UpdateUserDTO } from "./dto/update-user.dto";
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) { }
constructor(private readonly prisma: PrismaService) {}
async findAll({
skip,
@ -15,12 +15,14 @@ export class UserService {
cursor,
where,
orderBy,
include,
}: {
skip?: number;
take?: number;
cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput;
orderBy?: Record<string, unknown>;
include?: Prisma.UserInclude;
} = {}) {
return await this.prisma.user.findMany({
skip,
@ -28,6 +30,7 @@ export class UserService {
cursor,
where,
orderBy,
include,
});
}
@ -45,13 +48,10 @@ export class UserService {
});
if (!user) {
const isFirstUser = (await this.prisma.user.count()) === 0;
user = await this.prisma.user.create({
data: {
id,
username,
role: isFirstUser ? "ADMIN" : "STUDENT",
},
});
} else if (user.username !== username) {
@ -80,7 +80,6 @@ export class UserService {
where: { id },
data: {
username: updateUserInput.username,
role: updateUserInput.role,
},
});
}
@ -100,4 +99,14 @@ export class UserService {
},
});
}
async getProfile(id: string) {
return await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
username: true,
},
});
}
}

2
src/types/http.d.ts vendored
View File

@ -4,6 +4,6 @@ import { IncomingMessage } from "http";
declare module "http" {
interface IncomingMessage {
user?: UserEntity;
user?: User;
}
}

14
src/types/jwt.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import * as jwt from "jsonwebtoken";
declare module "jsonwebtoken" {
export interface UserJwtPayload extends jwt.JwtPayload {
name: string;
preferred_username: string;
email: string;
given_name: string;
family_name: string;
realm_access: {
roles: string[];
};
}
}