diff --git a/src/app.module.ts b/src/app.module.ts index 472f80ff28d2b1e4824e6f10870c57b7a0ad9f79..954b6366eef3e7b377d9be3b448da7831cac5f35 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,10 +22,6 @@ import { DashboardModule } from "./dashboard/dashboard.module"; import { BimbinganModule } from "./bimbingan/bimbingan.module"; import { Konfigurasi } from "./entities/konfigurasi.entity"; import { KonfigurasiModule } from "./konfigurasi/konfigurasi.module"; -import { DosenBimbinganModule } from "./dosen-bimbingan/dosen-bimbingan.module"; -import { ApprovalModule } from "./approval/approval.module"; -import { PendaftaranSidsem } from "./entities/pendaftaranSidsem"; -import { Ketersediaan } from "./entities/ketersediaan.entity"; import { validate } from "./env.validation"; import { BerkasBimbingan } from "./entities/berkasBimbingan"; import { MataKuliah } from "./entities/mataKuliah"; @@ -33,6 +29,7 @@ import { SubmisiTugas } from "./entities/submisiTugas"; import { KelasModule } from "./kelas/kelas.module"; import { BerkasSubmisiTugas } from "./entities/berkasSubmisiTugas"; import { BerkasTugas } from "./entities/berkasTugas"; +import { PendaftaranSidsem } from "./entities/pendaftaranSidsem"; @Module({ imports: [ @@ -61,7 +58,6 @@ import { BerkasTugas } from "./entities/berkasTugas"; Tugas, PengujiSidang, Konfigurasi, - Ketersediaan, MataKuliah, SubmisiTugas, BerkasSubmisiTugas, @@ -75,8 +71,6 @@ import { BerkasTugas } from "./entities/berkasTugas"; DashboardModule, BimbinganModule, KonfigurasiModule, - DosenBimbinganModule, - ApprovalModule, KelasModule, ], controllers: [AppController], diff --git a/src/approval/approval.controller.ts b/src/approval/approval.controller.ts deleted file mode 100644 index 866410794f5f767cd84ad5489fdcdc5817c9024b..0000000000000000000000000000000000000000 --- a/src/approval/approval.controller.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Controller, Patch, Param, UseGuards } from "@nestjs/common"; -import { ApprovalService } from "./approval.service"; -import { - PendaftaranTesis, - RegStatus, -} from "src/entities/pendaftaranTesis.entity"; -import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; -import { RolesGuard } from "src/middlewares/roles.guard"; -import { RoleEnum } from "src/entities/pengguna.entity"; -import { Roles } from "src/middlewares/roles.decorator"; -import { - ApiBearerAuth, - ApiCookieAuth, - ApiOkResponse, - ApiTags, -} from "@nestjs/swagger"; -import { ByIdParamDto } from "./approval.dto"; - -@ApiTags("Approval") -@ApiCookieAuth() -@ApiBearerAuth() -@Controller("approval") -@UseGuards(CustomAuthGuard, RolesGuard) -@Roles(RoleEnum.S2_PEMBIMBING) -export class ApprovalController { - constructor(private readonly approvalService: ApprovalService) {} - - @ApiOkResponse({ type: PendaftaranTesis }) - @Patch(":id/approve") - async approvePendaftaran( - @Param() param: ByIdParamDto, - ): Promise<PendaftaranTesis> { - return this.approvalService.approvePendaftaran( - param.id, - RegStatus.APPROVED, - ); - } - - @ApiOkResponse({ type: PendaftaranTesis }) - @Patch(":id/reject") - async declinePendaftaran( - @Param() param: ByIdParamDto, - ): Promise<PendaftaranTesis> { - return this.approvalService.approvePendaftaran( - param.id, - RegStatus.REJECTED, - ); - } - - @ApiOkResponse({ type: PendaftaranTesis }) - @Patch(":id/interview") - async interviewPendaftaran( - @Param() param: ByIdParamDto, - ): Promise<PendaftaranTesis> { - return this.approvalService.approvePendaftaran( - param.id, - RegStatus.INTERVIEW, - ); - } -} diff --git a/src/approval/approval.dto.ts b/src/approval/approval.dto.ts deleted file mode 100644 index b9fea475cfd34a9705b8b8fcd66f0a69ca1995d2..0000000000000000000000000000000000000000 --- a/src/approval/approval.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsUUID } from "@nestjs/class-validator"; -import { ApiProperty } from "@nestjs/swagger"; - -export class ByIdParamDto { - @IsUUID() - @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) - id: string; -} diff --git a/src/approval/approval.module.ts b/src/approval/approval.module.ts deleted file mode 100644 index c28790252c59277b51e473a33ab7d8b38ae32065..0000000000000000000000000000000000000000 --- a/src/approval/approval.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; -import { ApprovalController } from "./approval.controller"; -import { ApprovalService } from "./approval.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([PendaftaranTesis])], - controllers: [ApprovalController], - providers: [ApprovalService], -}) -export class ApprovalModule {} diff --git a/src/approval/approval.service.ts b/src/approval/approval.service.ts deleted file mode 100644 index 8163efeda7b43052e82b6a5441b5590f384f1b6a..0000000000000000000000000000000000000000 --- a/src/approval/approval.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { FindOneOptions, Repository } from "typeorm"; -import { - PendaftaranTesis, - RegStatus, -} from "src/entities/pendaftaranTesis.entity"; - -@Injectable() -export class ApprovalService { - constructor( - @InjectRepository(PendaftaranTesis) - private readonly pendaftaranRepository: Repository<PendaftaranTesis>, - ) {} - - async approvePendaftaran( - id: string, - status: RegStatus, - ): Promise<PendaftaranTesis> { - try { - const findOneOptions: FindOneOptions<PendaftaranTesis> = { - where: { id }, - }; - const pendaftaran = - await this.pendaftaranRepository.findOneOrFail(findOneOptions); - pendaftaran.status = status; - - return await this.pendaftaranRepository.save(pendaftaran); - } catch (error) { - throw new Error("Pendaftaran not found"); - } - } -} diff --git a/src/dosen-bimbingan/dosen-bimbingan.controller.ts b/src/dosen-bimbingan/dosen-bimbingan.controller.ts deleted file mode 100644 index ef0b6acae18dc2d24c5e25dbdf88eaa8158a46df..0000000000000000000000000000000000000000 --- a/src/dosen-bimbingan/dosen-bimbingan.controller.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - NotFoundException, - Put, - Query, - UseGuards, -} from "@nestjs/common"; -import { - ApiBearerAuth, - ApiCookieAuth, - ApiOkResponse, - ApiTags, -} from "@nestjs/swagger"; -import { RoleEnum } from "src/entities/pengguna.entity"; -import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; -import { Roles } from "src/middlewares/roles.decorator"; -import { RolesGuard } from "src/middlewares/roles.guard"; -import { - DosbimOptQueryDto, - DosbimQueryDto, - GetDosbimResDto, - SuccessResDto, - UpdateDosbimDto, -} from "./dosen-bimbingan.dto"; -import { DosenBimbinganService } from "./dosen-bimbingan.service"; - -@ApiTags("Dosen Bimbingan") -@ApiCookieAuth() -@ApiBearerAuth() -@Controller("dosen-bimbingan") -@UseGuards(CustomAuthGuard, RolesGuard) -export class DosenBimbinganController { - constructor(private readonly dosbimService: DosenBimbinganService) {} - - @ApiOkResponse({ type: [GetDosbimResDto] }) - @Roles( - RoleEnum.ADMIN, - RoleEnum.S2_TIM_TESIS, - RoleEnum.S2_MAHASISWA, - RoleEnum.S2_TIM_TESIS, - ) - @Get() - async get(@Query() query: DosbimOptQueryDto) { - if (!query.regId) return await this.dosbimService.getAll(); - - const res = await this.dosbimService.findByRegId(query.regId); - const mappedRes: GetDosbimResDto[] = res.map((r) => r.dosen); - return mappedRes; - } - - @ApiOkResponse({ type: SuccessResDto }) - @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) - @Put() - async updateByRegId( - @Query() query: DosbimQueryDto, - @Body() body: UpdateDosbimDto, - ): Promise<SuccessResDto> { - await this.dosbimService.updateByRegId(query.regId, body.dosbimIds); - - return { - status: "ok", - }; - } - - @ApiOkResponse({ type: SuccessResDto }) - @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) - @Delete() - async deleteByRegId(@Query() query: DosbimQueryDto): Promise<SuccessResDto> { - const res = await this.dosbimService.removeByRegId(query.regId); - - if (!res.affected) throw new NotFoundException(); - - return { - status: "ok", - }; - } -} diff --git a/src/dosen-bimbingan/dosen-bimbingan.dto.ts b/src/dosen-bimbingan/dosen-bimbingan.dto.ts deleted file mode 100644 index 11d56b1135d145102b6ac69a56474ae66975dac5..0000000000000000000000000000000000000000 --- a/src/dosen-bimbingan/dosen-bimbingan.dto.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - ArrayMaxSize, - IsArray, - IsUUID, - ArrayMinSize, - ArrayUnique, - IsOptional, -} from "@nestjs/class-validator"; -import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; -import { Pengguna } from "src/entities/pengguna.entity"; - -export class DosbimOptQueryDto { - @ApiPropertyOptional({ example: "550e8400-e29b-41d4-a716-446655440000" }) - @IsOptional() - @IsUUID() - regId?: string; -} - -export class DosbimQueryDto { - @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) - @IsUUID() - regId: string; -} - -export class UpdateDosbimDto { - @ApiProperty({ - type: [String], - example: ["550e8400-e29b-41d4-a716-446655440000"], - }) - @IsArray() - @IsUUID("all", { each: true }) - @ArrayMinSize(1) - @ArrayMaxSize(3) - @ArrayUnique() - dosbimIds: string[]; -} - -export class SuccessResDto { - @ApiProperty() - status: string; -} - -export class GetDosbimResDto extends PickType(Pengguna, [ - "id", - "email", - "nama", -] as const) {} diff --git a/src/dosen-bimbingan/dosen-bimbingan.module.ts b/src/dosen-bimbingan/dosen-bimbingan.module.ts deleted file mode 100644 index 1929f62932c353150ab780e6a6fb785c560a9357..0000000000000000000000000000000000000000 --- a/src/dosen-bimbingan/dosen-bimbingan.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Module } from "@nestjs/common"; -import { DosenBimbinganController } from "./dosen-bimbingan.controller"; -import { DosenBimbinganService } from "./dosen-bimbingan.service"; -import { AuthModule } from "src/auth/auth.module"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; -import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; -import { Pengguna } from "src/entities/pengguna.entity"; -import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; -import { CustomStrategy } from "src/middlewares/custom.strategy"; - -@Module({ - imports: [ - TypeOrmModule.forFeature([PendaftaranTesis, DosenBimbingan, Pengguna]), - AuthModule, - KonfigurasiModule, - ], - controllers: [DosenBimbinganController], - providers: [DosenBimbinganService, CustomStrategy], -}) -export class DosenBimbinganModule {} diff --git a/src/dosen-bimbingan/dosen-bimbingan.service.ts b/src/dosen-bimbingan/dosen-bimbingan.service.ts deleted file mode 100644 index 456364ee4bc51946474c2cd5e54e92fc26909fe8..0000000000000000000000000000000000000000 --- a/src/dosen-bimbingan/dosen-bimbingan.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - BadRequestException, - Injectable, - InternalServerErrorException, -} from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; -import { - PendaftaranTesis, - RegStatus, -} from "src/entities/pendaftaranTesis.entity"; -import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; -import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; -import { ArrayContains, DataSource, Repository } from "typeorm"; - -@Injectable() -export class DosenBimbinganService { - constructor( - @InjectRepository(DosenBimbingan) - private dosbimRepo: Repository<DosenBimbingan>, - @InjectRepository(Pengguna) - private penggunaRepo: Repository<Pengguna>, - @InjectRepository(PendaftaranTesis) - private pendaftaranRepo: Repository<PendaftaranTesis>, - private konfService: KonfigurasiService, - private dataSource: DataSource, - ) {} - - async getAll() { - return await this.penggunaRepo.find({ - select: { - id: true, - nama: true, - email: true, - }, - where: { - roles: ArrayContains([RoleEnum.S2_PEMBIMBING]), - }, - }); - } - - async findByRegId(regId: string) { - return await this.dosbimRepo.find({ - select: { - id: true, - dosen: { - id: true, - nama: true, - email: true, - }, - }, - relations: { - dosen: true, - }, - where: { - pendaftaran: { - id: regId, - }, - }, - }); - } - - async updateByRegId(regId: string, dosbimIds: string[]) { - const [reg, currPeriod] = await Promise.all([ - this.pendaftaranRepo.findOne({ - select: { id: true, status: true }, - where: { id: regId }, - relations: { topik: true }, - }), - this.konfService.getKonfigurasiByKey(process.env.KONF_PERIODE_KEY), - ]); - - if (!reg || reg.status !== RegStatus.APPROVED) { - throw new BadRequestException( - "Registrasi tidak ditemukan atau tidak disetujui.", - ); - } - - if (!currPeriod || currPeriod !== reg.topik.periode) { - throw new BadRequestException( - "Periode belum dikonfigurasi atau tidak sesuai dengan periode sekarang.", - ); - } - - for (const dosbimId of dosbimIds) { - const res = await this.penggunaRepo.findOne({ - select: { - id: true, - }, - where: { - id: dosbimId, - roles: ArrayContains([RoleEnum.S2_PEMBIMBING]), - }, - }); - - if (!res) { - throw new BadRequestException("Invalid pembimbing id"); - } - } - - const queryRunner = this.dataSource.createQueryRunner(); - - await queryRunner.connect(); - await queryRunner.startTransaction(); - try { - await this.removeByRegId(regId); - - for (const dosbimId of dosbimIds) { - await queryRunner.manager.getRepository(DosenBimbingan).insert({ - idPendaftaran: regId, - idDosen: dosbimId, - }); - } - - await queryRunner.commitTransaction(); - } catch (err) { - await queryRunner.rollbackTransaction(); - - throw new InternalServerErrorException(); - } finally { - await queryRunner.release(); - } - } - - async removeByRegId(regId: string) { - return await this.dosbimRepo - .createQueryBuilder() - .delete() - .where("idPendaftaran = :idPendaftaran", { - idPendaftaran: regId, - }) - .execute(); - } -} diff --git a/src/main.ts b/src/main.ts index 11cb941afa48d31dc673e827398bac25ee2df6c0..bb2da38e1a57d5422546e9ae52909898e1d51176 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,6 @@ async function bootstrap() { .setDescription("GraduIT API Documentation for S2 services") .setVersion("1.0") .addTag("Alokasi Topik") - .addTag("Approval") .addTag("Bimbingan") .addTag("Dashboard") .addTag("Dosen Bimbingan") diff --git a/src/registrasi-tesis/registrasi-tesis.controller.ts b/src/registrasi-tesis/registrasi-tesis.controller.ts index 4b0904fe472ce0dba0b8363e62f53291e5988af4..e41920cdafcd7e91a4378bd0224bfdbcb9605ec7 100644 --- a/src/registrasi-tesis/registrasi-tesis.controller.ts +++ b/src/registrasi-tesis/registrasi-tesis.controller.ts @@ -4,7 +4,6 @@ import { Controller, ForbiddenException, Get, - NotFoundException, Param, Patch, Post, @@ -16,6 +15,7 @@ import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, + ApiOperation, ApiTags, } from "@nestjs/swagger"; import { Request } from "express"; @@ -27,9 +27,10 @@ import { Roles } from "src/middlewares/roles.decorator"; import { RolesGuard } from "src/middlewares/roles.guard"; import { FindAllNewestRegRespDto, + GetByIdRespDto, + IdDto, RegByMhsParamDto, RegDto, - RegParamDto, RegQueryDto, RegStatisticsRespDto, UpdateByMhsParamsDto, @@ -50,13 +51,10 @@ export class RegistrasiTesisController { private readonly konfService: KonfigurasiService, ) {} - @UseGuards(CustomAuthGuard, RolesGuard) - @Roles(RoleEnum.S2_MAHASISWA, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) - @Get("/mahasiswa/:mahasiswaId") - findByUserId(@Param() params: RegByMhsParamDto) { - return this.registrasiTesisService.findByUserId(params.mahasiswaId); - } - + @ApiOperation({ + summary: + "Create new registration. Roles: S2_MAHASISWA, ADMIN, S2_TIM_TESIS", + }) @UseGuards(CustomAuthGuard, RolesGuard) @Roles(RoleEnum.S2_MAHASISWA, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) @Post() @@ -72,7 +70,88 @@ export class RegistrasiTesisController { ); } - // Right now only admin & timtesis view is handled (apakah dosen perlu summary juga?) + @ApiOperation({ + summary: + "Find registrations (historical) by Mahasiswa ID. Roles: S2_MAHASISWA, ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: [GetByIdRespDto] }) + @UseGuards(CustomAuthGuard, RolesGuard) + @Roles(RoleEnum.S2_MAHASISWA, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) + @Get("/mahasiswa/:mahasiswaId") + async findByUserId(@Param() params: RegByMhsParamDto, @Req() req: Request) { + const { id, roles } = req.user as AuthDto; + + if ( + !roles.includes(RoleEnum.ADMIN) && + !roles.includes(RoleEnum.S2_TIM_TESIS) + ) { + // roles only include RoleEnum.S2_MAHASISWA + if (id !== params.mahasiswaId) { + throw new ForbiddenException(); + } + } + + const periode = await this.konfService.getKonfigurasiByKey( + process.env.KONF_PERIODE_KEY, + ); + + if (!periode) { + throw new BadRequestException("Periode belum dikonfigurasi."); + } + + return this.registrasiTesisService.findByUserId( + params.mahasiswaId, + periode, + false, + undefined, + ); + } + + @ApiOperation({ + summary: + "Find newest registration by Mahasiswa ID. Roles: ADMIN, S2_TIM_TESIS, S2_PEMBIMBING", + }) + @ApiOkResponse({ type: GetByIdRespDto }) + @UseGuards(CustomAuthGuard, RolesGuard) + @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS, RoleEnum.S2_PEMBIMBING) + @Get("/mahasiswa/:mahasiswaId/newest") + async findNewestByUserId( + @Param() params: RegByMhsParamDto, + @Req() req: Request, + ) { + const { id, roles } = req.user as AuthDto; + + let idPenerima = undefined; + if ( + !roles.includes(RoleEnum.ADMIN) && + !roles.includes(RoleEnum.S2_TIM_TESIS) + ) { + // roles only include RoleEnum.S2_PEMBIMBING + idPenerima = id; + } + + const periode = await this.konfService.getKonfigurasiByKey( + process.env.KONF_PERIODE_KEY, + ); + + if (!periode) { + throw new BadRequestException("Periode belum dikonfigurasi."); + } + + const res = await this.registrasiTesisService.findByUserId( + params.mahasiswaId, + periode, + true, + idPenerima, + ); + + return res[0]; + } + + @ApiOperation({ + summary: + "Get statistics of registrations. Roles: S2_PEMBIMBING, ADMIN, S2_TIM_TESIS", + }) @ApiOkResponse({ type: RegStatisticsRespDto }) @UseGuards(CustomAuthGuard, RolesGuard) @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) @@ -96,6 +175,10 @@ export class RegistrasiTesisController { // Admin & TimTesis view will show newst reg records per Mahasiswa // Pembimbing view will show all regs towards them + @ApiOperation({ + summary: + "Find all newest registration for each Mahasiswa. Roles: S2_PEMBIMBING, ADMIN, S2_TIM_TESIS", + }) @ApiOkResponse({ type: FindAllNewestRegRespDto, isArray: true }) @UseGuards(CustomAuthGuard, RolesGuard) @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) @@ -128,42 +211,18 @@ export class RegistrasiTesisController { }); } + @ApiOperation({ + summary: + "Update interview date of newest in process registration by Mahasiswa ID. Roles: S2_PEMBIMBING, ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: IdDto }) @UseGuards(CustomAuthGuard, RolesGuard) - @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) - @Get("/:id") - async findById( - @Req() req: Request, - @Param() params: RegParamDto, - @Query() - query: ViewQueryDto, - ) { - const { id: idPenerima, roles } = req.user as AuthDto; - - if (!roles.includes(query.view)) { - throw new ForbiddenException(); - } - - const res = await this.registrasiTesisService.findRegById(params.id); - if (!res) { - throw new NotFoundException(); - } - - if ( - query.view === RoleEnum.S2_PEMBIMBING && - res.penerima.id !== idPenerima - ) { - throw new ForbiddenException(); - } - - return res; - } - - @UseGuards(CustomAuthGuard, RolesGuard) - @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) + @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS, RoleEnum.S2_PEMBIMBING) @Patch("/:mhsId/interview") async updateInterviewDateByMhsId( @Param() params: UpdateByMhsParamsDto, @Body() body: UpdateInterviewBodyDto, + @Req() req: Request, ) { const periode = await this.konfService.getKonfigurasiByKey( process.env.KONF_PERIODE_KEY, @@ -173,19 +232,37 @@ export class RegistrasiTesisController { throw new BadRequestException("Periode belum dikonfigurasi."); } + const { id, roles } = req.user as AuthDto; + let idPenerima = undefined; + + if ( + !roles.includes(RoleEnum.ADMIN) && + !roles.includes(RoleEnum.S2_TIM_TESIS) + ) { + // roles only include RoleEnum.S2_PEMBIMBING + idPenerima = id; + } + return await this.registrasiTesisService.updateInterviewDate( params.mhsId, periode, body, + idPenerima, ); } + @ApiOperation({ + summary: + "Update status of newest registration by Mahasiswa ID. Roles: S2_PEMBIMBING, ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: IdDto }) @UseGuards(CustomAuthGuard, RolesGuard) - @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) + @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS, RoleEnum.S2_PEMBIMBING) @Patch("/:mhsId/status") async updateStatusByMhsId( @Param() params: UpdateByMhsParamsDto, @Body() body: UpdateStatusBodyDto, + @Req() req: Request, ) { const periode = await this.konfService.getKonfigurasiByKey( process.env.KONF_PERIODE_KEY, @@ -195,17 +272,34 @@ export class RegistrasiTesisController { throw new BadRequestException("Periode belum dikonfigurasi."); } + const { id, roles } = req.user as AuthDto; + let idPenerima = undefined; + + if ( + !roles.includes(RoleEnum.ADMIN) && + !roles.includes(RoleEnum.S2_TIM_TESIS) + ) { + // roles only include RoleEnum.S2_PEMBIMBING + idPenerima = id; + } + return await this.registrasiTesisService.updateStatus( params.mhsId, periode, body, + idPenerima, ); } + @ApiOperation({ + summary: + "Update pembimbing list of approved registration by Mahasiswa ID. Roles: ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: IdDto }) @UseGuards(CustomAuthGuard, RolesGuard) @Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) @Patch("/:mhsId/pembimbing") - async udpatePembimbingListByMhsId( + async updatePembimbingListByMhsId( @Param() params: UpdateByMhsParamsDto, @Body() body: UpdatePembimbingBodyDto, ) { diff --git a/src/registrasi-tesis/registrasi-tesis.dto.ts b/src/registrasi-tesis/registrasi-tesis.dto.ts index f18cca53a0f91f6503197d1318f7ca283cb85015..330f31d2bf8ea6118fa74403d210c1cf87e13921 100644 --- a/src/registrasi-tesis/registrasi-tesis.dto.ts +++ b/src/registrasi-tesis/registrasi-tesis.dto.ts @@ -6,10 +6,14 @@ import { IsString, IsUUID, } from "@nestjs/class-validator"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; import { ArrayMinSize, ArrayUnique, IsArray } from "class-validator"; -import { JalurEnum, RegStatus } from "src/entities/pendaftaranTesis.entity"; -import { RoleEnum } from "src/entities/pengguna.entity"; +import { + JalurEnum, + PendaftaranTesis, + RegStatus, +} from "src/entities/pendaftaranTesis.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; export class RegDto { @IsUUID() @@ -39,13 +43,21 @@ export class RegByMhsParamDto { mahasiswaId: string; } -export class RegParamDto { +export class IdDto { @IsUUID() @ApiProperty() id: string; } -export class RegQueryDto { +export class ViewQueryDto { + @IsEnum([RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS]) + @ApiProperty({ + enum: [RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS], + }) + view: RoleEnum.S2_PEMBIMBING | RoleEnum.ADMIN | RoleEnum.S2_TIM_TESIS; +} + +export class RegQueryDto extends ViewQueryDto { @IsOptional() @IsNumberString() @ApiPropertyOptional() @@ -75,35 +87,29 @@ export class RegQueryDto { @IsEnum(["ASC", "DESC"]) @ApiPropertyOptional({ enum: ["ASC", "DESC"] }) sort?: "ASC" | "DESC"; - - @IsEnum([RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS]) - @ApiProperty({ - enum: [RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS], - }) - view: RoleEnum.S2_PEMBIMBING | RoleEnum.ADMIN | RoleEnum.S2_TIM_TESIS; -} - -export class ViewQueryDto { - @IsEnum([RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS]) - @ApiProperty({ - enum: [RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS], - }) - view: RoleEnum.S2_PEMBIMBING | RoleEnum.ADMIN | RoleEnum.S2_TIM_TESIS; } export class FindAllNewestRegRespDataDto { @ApiProperty() pendaftaran_id: string; + @ApiProperty() nim: string; + @ApiProperty() mahasiswa_nama: string; + @ApiProperty() mahasiswa_id: string; + @ApiProperty() pembimbing_nama: string; + @ApiProperty() status: string; + + @ApiProperty() + jadwal_interview: Date; } export class FindAllNewestRegRespDto { @@ -158,3 +164,26 @@ export class UpdatePembimbingBodyDto { @ArrayUnique() pembimbing_ids: string[]; } + +class DosenPembimbingDto extends PickType(Pengguna, [ + "id", + "nama", + "kontak", +] as const) {} + +export class GetByIdRespDto extends PickType(PendaftaranTesis, [ + "id", + "jadwalInterview", + "status", + "jalurPilihan", + "waktuPengiriman", +] as const) { + @ApiProperty() + judulTopik: string; + + @ApiProperty() + deskripsiTopik: string; + + @ApiProperty({ type: [DosenPembimbingDto] }) + dosenPembimbing: DosenPembimbingDto[]; +} diff --git a/src/registrasi-tesis/registrasi-tesis.service.ts b/src/registrasi-tesis/registrasi-tesis.service.ts index 1513caa4c7d7cae5ada90e6c5857f93349c6b533..74afb3e99311335adae3e191090ce7b4902964cb 100644 --- a/src/registrasi-tesis/registrasi-tesis.service.ts +++ b/src/registrasi-tesis/registrasi-tesis.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, @@ -14,15 +15,17 @@ import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; import { Topik } from "src/entities/topik.entity"; import { generateQueryBuilderOrderByObj } from "src/helper/sorting"; import { validateId } from "src/helper/validation"; -import { ArrayContains, DataSource, In, Repository } from "typeorm"; +import { ArrayContains, Brackets, DataSource, In, Repository } from "typeorm"; import { FindAllNewestRegRespDto, + IdDto, RegDto, RegStatisticsRespDto, UpdateInterviewBodyDto, UpdatePembimbingBodyDto, UpdateStatusBodyDto, } from "./registrasi-tesis.dto"; +import * as dayjs from "dayjs"; @Injectable() export class RegistrasiTesisService { @@ -84,21 +87,73 @@ export class RegistrasiTesisService { return createdRegistration; } - async findByUserId(mahasiswaId: string) { - const res = await this.pendaftaranTesisRepository.find({ - relations: ["topik", "penerima"], - where: { mahasiswa: { id: mahasiswaId } }, - }); + async findByUserId( + mahasiswaId: string, + periode: string, + isNewestOnly: boolean, + idPenerima?: string, + ) { + const baseQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pt") + .select("pt.id") + .addSelect("pt.jadwalInterview") + .addSelect("pt.status") + .addSelect("pt.jalurPilihan") + .addSelect("pt.waktuPengiriman") + .addSelect("topik.judul") + .addSelect("penerima.id") + .addSelect("penerima.nama") + .addSelect("dosenBimbingan") + .addSelect("dosen.id") + .addSelect("dosen.nama") + .addSelect("dosen.kontak") + .addSelect("topik.judul") + .addSelect("topik.deskripsi") + .leftJoin("pt.topik", "topik") + .leftJoin("pt.penerima", "penerima") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .where("pt.mahasiswaId = :mahasiswaId", { mahasiswaId }) + .andWhere("topik.periode = :periode", { periode }) + .orderBy("pt.waktuPengiriman", "DESC"); + + const res = await baseQuery.getMany(); + + if (res.length === 0) { + throw new NotFoundException("Tidak ada registrasi tesis yang ditemukan."); + } - return res.map((r) => ({ - ...r, - penerima: { - ...r.penerima, - password: undefined, - roles: undefined, - nim: undefined, - }, + if (idPenerima) { + // requester only has S2_PEMBIMBING access + const reg = res[0]; + + if (reg.penerima.id !== idPenerima) { + throw new ForbiddenException(); + } + } + + const mappedRes = res.map((r) => ({ + id: r.id, + jadwalInterview: r.jadwalInterview, + jalurPilihan: r.jalurPilihan, + status: r.status, + waktuPengiriman: r.waktuPengiriman, + judulTopik: r.topik.judul, + deskripsiTopik: r.topik.deskripsi, + dosenPembimbing: + r.status === RegStatus.APPROVED + ? r.dosenBimbingan.map((db) => db.dosen) + : [r.penerima], })); + + if (isNewestOnly) { + // only get last registration + // slow performance because get all records first then only returns the first one + // need to change to use subquery + mappedRes.splice(1); + } + + return mappedRes; } async getRegsStatistics(options: { @@ -109,69 +164,70 @@ export class RegistrasiTesisService { where: { roles: ArrayContains([RoleEnum.S2_MAHASISWA]) }, }); - // Show newest regs per Mhs if POV TimTesis or Admin - if (!options.idPenerima) { - const baseQuery = this.pendaftaranTesisRepository - .createQueryBuilder("pt") - .innerJoinAndSelect( - (qb) => - qb - .select([ - "pt.mahasiswaId AS latest_mahasiswaId", - "MAX(pt.waktuPengiriman) AS latestPengiriman", - ]) - .from(PendaftaranTesis, "pt") - .groupBy("pt.mahasiswaId"), - "latest", - "latest.latest_mahasiswaId = pt.mahasiswaId AND pt.waktuPengiriman = latest.latestPengiriman", - ) - .innerJoinAndSelect("pt.topik", "topik") - .where("topik.periode = :periode", { periode: options.periode }); - - const totalDiterima = baseQuery - .clone() - .andWhere("pt.status = :status", { status: RegStatus.APPROVED }) - .getCount(); - - const totalProses = baseQuery - .clone() - .where("pt.status IN (:...status)", { - status: [RegStatus.NOT_ASSIGNED, RegStatus.INTERVIEW], - }) - .getCount(); - - const totalDitolak = baseQuery - .clone() - .where("pt.status = :status", { status: RegStatus.REJECTED }) - .getCount(); - - const [total, diterima, proses, ditolak] = await Promise.all([ - totalMahasiswa, - totalDiterima, - totalProses, - totalDitolak, - ]); - - return { - diterima: { - amount: diterima, - percentage: Math.round((diterima / total) * 100), - }, - sedang_proses: { - amount: proses, - percentage: Math.round((proses / total) * 100), - }, - ditolak: { - amount: ditolak, - percentage: Math.round((ditolak / total) * 100), - }, - }; - } else { - throw new InternalServerErrorException("Not implemented"); + // Show newest regs per Mhs + const baseQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pt") + .innerJoinAndSelect( + (qb) => + qb + .select([ + "pt.mahasiswaId AS latest_mahasiswaId", + "MAX(pt.waktuPengiriman) AS latestPengiriman", + ]) + .from(PendaftaranTesis, "pt") + .groupBy("pt.mahasiswaId"), + "latest", + "latest.latest_mahasiswaId = pt.mahasiswaId AND pt.waktuPengiriman = latest.latestPengiriman", + ) + .innerJoinAndSelect("pt.topik", "topik") + .where("topik.periode = :periode", { periode: options.periode }); + + if (options.idPenerima) { + baseQuery.andWhere("pt.penerimaId = :idPenerima", { + idPenerima: options.idPenerima, + }); } + + const totalDiterima = baseQuery + .clone() + .andWhere("pt.status = :status", { status: RegStatus.APPROVED }) + .getCount(); + + const totalProses = baseQuery + .clone() + .andWhere("pt.status IN (:...status)", { + status: [RegStatus.NOT_ASSIGNED, RegStatus.INTERVIEW], + }) + .getCount(); + + const totalDitolak = baseQuery + .clone() + .andWhere("pt.status = :status", { status: RegStatus.REJECTED }) + .getCount(); + + const [total, diterima, proses, ditolak] = await Promise.all([ + totalMahasiswa, + totalDiterima, + totalProses, + totalDitolak, + ]); + + return { + diterima: { + amount: diterima, + percentage: Math.round((diterima / total) * 100), + }, + sedang_proses: { + amount: proses, + percentage: Math.round((proses / total) * 100), + }, + ditolak: { + amount: ditolak, + percentage: Math.round((ditolak / total) * 100), + }, + }; } - // TODO sort async findAllRegs(options: { status?: RegStatus; page: number; @@ -186,22 +242,20 @@ export class RegistrasiTesisService { .createQueryBuilder("pt") .select("pt"); - // Show newest regs per Mhs if POV TimTesis or Admin + // Show newest regs per Mhs // May need to make materialized view to improve performance - if (!options.idPenerima) { - baseQuery.innerJoinAndSelect( - (qb) => - qb - .select([ - "pt.mahasiswaId AS latest_mahasiswaId", - "MAX(pt.waktuPengiriman) AS latestPengiriman", - ]) - .from(PendaftaranTesis, "pt") - .groupBy("pt.mahasiswaId"), - "latest", - "latest.latest_mahasiswaId = pt.mahasiswaId AND pt.waktuPengiriman = latest.latestPengiriman", - ); - } + baseQuery.innerJoinAndSelect( + (qb) => + qb + .select([ + "pt.mahasiswaId AS latest_mahasiswaId", + "MAX(pt.waktuPengiriman) AS latestPengiriman", + ]) + .from(PendaftaranTesis, "pt") + .groupBy("pt.mahasiswaId"), + "latest", + "latest.latest_mahasiswaId = pt.mahasiswaId AND pt.waktuPengiriman = latest.latestPengiriman", + ); baseQuery .innerJoinAndSelect("pt.topik", "topik") @@ -209,19 +263,25 @@ export class RegistrasiTesisService { .innerJoinAndSelect("pt.mahasiswa", "mahasiswa") .where("topik.periode = :periode", { periode: options.periode }); + if (options.idPenerima) { + baseQuery.andWhere("pt.penerimaId = :idPenerima", { + idPenerima: options.idPenerima, + }); + } + if (options.search) baseQuery.andWhere( - "mahasiswa.nama LIKE '%' || :search || '%' OR mahasiswa.nim LIKE '%' || :search || '%'", - { - search: options.search, - }, + new Brackets((qb) => + qb + .where("mahasiswa.nama ILIKE :search", { + search: `%${options.search}%`, + }) + .orWhere("mahasiswa.nim ILIKE :search", { + search: `%${options.search}%`, + }), + ), ); - if (options.idPenerima) - baseQuery.andWhere("penerima.id = :idPenerima", { - idPenerima: options.idPenerima, - }); - if (options.status) baseQuery.andWhere("pt.status = :status", { status: options.status, @@ -256,6 +316,7 @@ export class RegistrasiTesisService { mahasiswa_nama: reg.mahasiswa.nama, pembimbing_nama: reg.penerima.nama, status: reg.status, + jadwal_interview: reg.jadwalInterview, })), count, }; @@ -263,40 +324,7 @@ export class RegistrasiTesisService { return resData; } - async findRegById(id: string) { - // not periode-protected - return await this.pendaftaranTesisRepository.findOne({ - select: { - id: true, - waktuPengiriman: true, - jadwalInterview: true, - waktuKeputusan: true, - status: true, - jalurPilihan: true, - penerima: { - id: true, - nama: true, - email: true, - }, - mahasiswa: { - id: true, - nama: true, - email: true, - nim: true, - }, - }, - where: { - id, - }, - relations: { - penerima: true, - topik: true, - mahasiswa: true, - }, - }); - } - - async getNewestRegByMhs(mahasiswaId: string, periode: string) { + private async getNewestRegByMhs(mahasiswaId: string, periode: string) { const mahasiswa = await this.penggunaRepository.findOne({ select: { id: true, @@ -313,14 +341,23 @@ export class RegistrasiTesisService { const newestReg = await this.pendaftaranTesisRepository.findOne({ select: { id: true, + jadwalInterview: true, status: true, waktuPengiriman: true, + jalurPilihan: true, topik: { + judul: true, + deskripsi: true, periode: true, }, + penerima: { + id: true, + }, }, relations: { topik: true, + penerima: true, + mahasiswa: true, }, where: { mahasiswa: mahasiswa, @@ -345,9 +382,23 @@ export class RegistrasiTesisService { mahasiswaId: string, periode: string, dto: UpdateInterviewBodyDto, + idPenerima?: string, ) { + const minDate = new Date(); + minDate.setDate(minDate.getDate() + 2); + + if (dayjs(dto.date).isBefore(dayjs(minDate).endOf("d"))) { + throw new BadRequestException( + "Interview date must be at least 2 days from now", + ); + } + const newestReg = await this.getNewestRegByMhs(mahasiswaId, periode); + if (newestReg && idPenerima && newestReg.penerima.id !== idPenerima) { + throw new ForbiddenException(); + } + const restrictedStatus: RegStatus[] = [ RegStatus.APPROVED, RegStatus.REJECTED, @@ -367,22 +418,54 @@ export class RegistrasiTesisService { { jadwalInterview: newDate, status: RegStatus.INTERVIEW }, ); - return { status: "ok" }; + return { id: newestReg.id } as IdDto; } async updateStatus( mahasiswaId: string, periode: string, dto: UpdateStatusBodyDto, + idPenerima?: string, ) { const newestReg = await this.getNewestRegByMhs(mahasiswaId, periode); - await this.pendaftaranTesisRepository.update( - { id: newestReg.id }, - { status: dto.status }, - ); + if (newestReg && idPenerima && newestReg.penerima.id !== idPenerima) { + throw new ForbiddenException(); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.update( + PendaftaranTesis, + { id: newestReg.id }, + { status: dto.status, waktuKeputusan: new Date() }, + ); + + if (dto.status === RegStatus.APPROVED) { + await queryRunner.manager.insert(DosenBimbingan, { + idPendaftaran: newestReg.id, + idDosen: newestReg.penerima.id, + }); + } else { + // dto.status === RegStatus.REJECTED + await queryRunner.manager.delete(DosenBimbingan, { + idPendaftaran: newestReg.id, + }); + } - return { status: "ok" }; + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + + return { id: newestReg.id } as IdDto; } async updatePembimbingList( @@ -392,7 +475,6 @@ export class RegistrasiTesisService { ) { const newestReg = await this.getNewestRegByMhs(mahasiswaId, periode); - // TODO decide to allow unapproved Registrations to have their Penerima changed or not if (newestReg.status !== RegStatus.APPROVED) throw new BadRequestException( "Cannot update pembimbing on non-approved registration", @@ -427,7 +509,7 @@ export class RegistrasiTesisService { (newId) => !newPembimbingIds.includes(newId), ); - const queryRunner = await this.dataSource.createQueryRunner(); + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); @@ -443,10 +525,12 @@ export class RegistrasiTesisService { await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); + + throw new InternalServerErrorException(); } finally { await queryRunner.release(); } - return { status: "ok" }; + return { id: newestReg.id } as IdDto; } }