diff --git a/src/app.module.ts b/src/app.module.ts index 3dd3d3ee0049d15609edbdae4563daac699c1ac7..25728d3124a279326173b50c046e2cb22dc0ac8a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; -import { TypeOrmModule } from "@nestjs/typeorm"; import { Bimbingan } from "./entities/bimbingan.entity"; import { Pengguna } from "./entities/pengguna.entity"; import { Topik } from "./entities/topik.entity"; @@ -10,9 +10,9 @@ import { PendaftaranTesis } from "./entities/pendaftaranTesis.entity"; import { PengujiSidsem } from "./entities/pengujiSidsem.entity"; import { RegistrasiTesisModule } from "./registrasi-tesis/registrasi-tesis.module"; import { ConfigModule } from "@nestjs/config"; -import { AuthModule } from "./auth/auth.module"; +import { RegistrasiSidsemModule } from "./registrasi-sidsem/registrasi-sidsem.module"; import { AlokasiTopikModule } from "./alokasi-topik/alokasi-topik.module"; -import { DashboardModule } from "./dashboard/dashboard.module"; +import { AuthModule } from "./auth/auth.module"; import { BimbinganModule } from "./bimbingan/bimbingan.module"; import { validate } from "./env.validation"; import { BerkasBimbingan } from "./entities/berkasBimbingan.entity"; @@ -21,6 +21,9 @@ import { DosenBimbinganModule } from "./dosen-bimbingan/dosen-bimbingan.module"; import { PenggunaModule } from "./pengguna/pengguna.module"; import { KonfigurasiModule } from "./konfigurasi/konfigurasi.module"; import { Konfigurasi } from "./entities/konfigurasi.entity"; +import { DashboardModule } from "./dashboard/dashboard.module"; +import { BerkasSidsem } from "./entities/berkasSidsem.entity"; +import { DosenPengujiModule } from "./dosen-penguji/dosen-penguji.module"; @Module({ imports: [ @@ -43,6 +46,7 @@ import { Konfigurasi } from "./entities/konfigurasi.entity"; Konfigurasi, PendaftaranTesis, PengujiSidsem, + BerkasSidsem, ], synchronize: true, }), @@ -52,8 +56,10 @@ import { Konfigurasi } from "./entities/konfigurasi.entity"; DashboardModule, BimbinganModule, DosenBimbinganModule, + RegistrasiSidsemModule, PenggunaModule, KonfigurasiModule, + DosenPengujiModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/dosen-bimbingan/dosen-bimbingan.dto.ts b/src/dosen-bimbingan/dosen-bimbingan.dto.ts index 16e493a95a99c7f62c02cf894b7386b3e5974383..47a532f830f4d2f30afeff37c5e57b5a6a3e4b82 100644 --- a/src/dosen-bimbingan/dosen-bimbingan.dto.ts +++ b/src/dosen-bimbingan/dosen-bimbingan.dto.ts @@ -1,15 +1,6 @@ -import { IsOptional } from "@nestjs/class-validator"; -import { ApiPropertyOptional, PickType } from "@nestjs/swagger"; -import { IsUUID } from "class-validator"; +import { 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 GetDosbimResDto extends PickType(Pengguna, [ "id", "email", diff --git a/src/dosen-penguji/dosen-penguji.controller.ts b/src/dosen-penguji/dosen-penguji.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..d50cb97c330b731985268617b2e50fe6087c7066 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + 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 { GetDosujiResDto } from "./dosen-penguji.dto"; +import { DosenPengujiService } from "./dosen-penguji.service"; + +@ApiTags("Dosen Penguji") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("dosen-penguji") +@UseGuards(CustomAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) +export class DosenPengujiController { + constructor(private readonly dosujiService: DosenPengujiService) {} + + @ApiOkResponse({ type: [GetDosujiResDto] }) + @ApiOperation({ + summary: "Get all available dosen penguji. Roles: ADMIN, S2_TIM_TESIS", + }) + @Get() + async get() { + return await this.dosujiService.getAll(); + } +} diff --git a/src/dosen-penguji/dosen-penguji.dto.ts b/src/dosen-penguji/dosen-penguji.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..99edd73b7e11ddf929d78756346cdf4efd0913fb --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.dto.ts @@ -0,0 +1,9 @@ +import { PickType } from "@nestjs/swagger"; +import { Pengguna } from "src/entities/pengguna.entity"; + +export class GetDosujiResDto extends PickType(Pengguna, [ + "id", + "email", + "nama", + "keahlian", +] as const) {} diff --git a/src/dosen-penguji/dosen-penguji.module.ts b/src/dosen-penguji/dosen-penguji.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c64d3a567c20ecd9f1e949e6f99d7e998db7d13 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "src/auth/auth.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { DosenPengujiController } from "./dosen-penguji.controller"; +import { DosenPengujiService } from "./dosen-penguji.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([Pengguna]), AuthModule], + controllers: [DosenPengujiController], + providers: [DosenPengujiService, CustomStrategy], +}) +export class DosenPengujiModule {} diff --git a/src/dosen-penguji/dosen-penguji.service.ts b/src/dosen-penguji/dosen-penguji.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..712b5ced205297b509b4d23820557969a7f51888 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { ArrayContains, Repository } from "typeorm"; + +@Injectable() +export class DosenPengujiService { + constructor( + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + ) {} + + async getAll() { + return await this.penggunaRepo.find({ + select: { + id: true, + nama: true, + email: true, + keahlian: true, + }, + where: { + roles: ArrayContains([RoleEnum.S2_PENGUJI]), + }, + }); + } +} diff --git a/src/entities/berkasSidsem.entity.ts b/src/entities/berkasSidsem.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..7105028e2c852feb7127e4d7837236f2752ec58d --- /dev/null +++ b/src/entities/berkasSidsem.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { IsString, IsUrl } from "@nestjs/class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { PendaftaranSidsem } from "./pendaftaranSidsem"; + +@Entity() +export class BerkasSidsem { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne( + () => PendaftaranSidsem, + (pendaftaranSidsem) => pendaftaranSidsem.id, + { + orphanedRowAction: "delete", + }, + ) + pendaftaranSidsem: PendaftaranSidsem; + + @Column({ type: "text" }) + @IsString() + @ApiProperty() + nama: string; + + @Column({ type: "text" }) + @IsUrl() + @ApiProperty({ example: "https://example.com/berkas.pdf" }) + url: string; +} diff --git a/src/entities/pendaftaranSidsem.ts b/src/entities/pendaftaranSidsem.ts index a733c9cd7aedf8825202a92c3b509e271486847f..901bcd467d91ca787f2cbb58f0cb70f7ad19291b 100644 --- a/src/entities/pendaftaranSidsem.ts +++ b/src/entities/pendaftaranSidsem.ts @@ -9,6 +9,9 @@ import { PendaftaranTesis } from "./pendaftaranTesis.entity"; // import { Ruangan } from "./ruangan.entity"; import { ApiProperty } from "@nestjs/swagger"; import { PengujiSidsem } from "./pengujiSidsem.entity"; +import { BerkasSidsem } from "./berkasSidsem.entity"; +import { IsEnum, IsUUID } from "@nestjs/class-validator"; +import { IsDateString, IsNotEmpty, IsString } from "class-validator"; export enum TipeSidsemEnum { SEMINAR_1 = "SEMINAR_1", @@ -16,47 +19,83 @@ export enum TipeSidsemEnum { SIDANG = "SIDANG", } +export enum SidsemStatus { + NOT_ASSIGNED = "NOT_ASSIGNED", + REJECTED = "REJECTED", + APPROVED = "APPROVED", +} + @Entity() export class PendaftaranSidsem { @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() @PrimaryGeneratedColumn("uuid") id: string; @ApiProperty({ enum: TipeSidsemEnum }) + @IsEnum(TipeSidsemEnum) @Column({ type: "enum", enum: TipeSidsemEnum }) tipe: TipeSidsemEnum; @ApiProperty() - @Column({ type: "boolean", default: false }) - ditolak: boolean; - - @ApiProperty() - @Column({ type: "boolean", nullable: true }) - lulus: boolean; + @IsEnum(SidsemStatus) + @Column({ + type: "enum", + enum: SidsemStatus, + default: SidsemStatus.NOT_ASSIGNED, + }) + status: SidsemStatus; @ApiProperty() + @IsDateString() @Column({ type: "timestamptz", nullable: true }) - waktuMulai: Date; + jadwal: Date; @ApiProperty() - @Column({ type: "timestamptz", nullable: true }) - waktuSelesai: Date; + @IsString() + @IsNotEmpty() + @Column({ type: "text" }) + judulSidsem: string; @ApiProperty() - @Column({ type: "text", nullable: true }) - linkw2m: string; + @IsString() + @IsNotEmpty() + @Column({ type: "text" }) + deskripsiSidsem: string; @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) pendaftaranTesis: PendaftaranTesis; - // @ApiProperty({ type: Ruangan, nullable: true }) - // @ManyToOne(() => Ruangan, (ruangan) => ruangan.id) - // ruangan: Ruangan; - @ApiProperty() + @IsString() + @IsNotEmpty() @Column({ type: "text", nullable: true }) ruangan: string; @OneToMany(() => PengujiSidsem, (pengujiSidsem) => pengujiSidsem.sidsem) penguji: PengujiSidsem[]; + + @ApiProperty({ type: [BerkasSidsem] }) + @OneToMany( + () => BerkasSidsem, + (berkasSidsem) => berkasSidsem.pendaftaranSidsem, + { + cascade: true, + }, + ) + berkasSidsem: BerkasSidsem[]; + + @ApiProperty() + @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) + waktuPengiriman: Date; +} + +export function cmpTipeSidsem(a: TipeSidsemEnum, b: TipeSidsemEnum): number { + const tipeSidsemOrder = { + [TipeSidsemEnum.SEMINAR_1]: 1, + [TipeSidsemEnum.SEMINAR_2]: 2, + [TipeSidsemEnum.SIDANG]: 3, + }; + + return tipeSidsemOrder[a] - tipeSidsemOrder[b]; } diff --git a/src/entities/pengujiSidsem.entity.ts b/src/entities/pengujiSidsem.entity.ts index 09aebe7c16acbb068ae8a26c341b02e996e6a7a3..934f0da7dbfc9520ee87e55c12492db4575a203b 100644 --- a/src/entities/pengujiSidsem.entity.ts +++ b/src/entities/pengujiSidsem.entity.ts @@ -1,4 +1,10 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { Pengguna } from "./pengguna.entity"; import { PendaftaranSidsem } from "./pendaftaranSidsem"; @@ -11,8 +17,16 @@ export class PengujiSidsem { () => PendaftaranSidsem, (pendaftaranSidsem) => pendaftaranSidsem.id, ) + @JoinColumn({ name: "idSidsem" }) sidsem: PendaftaranSidsem; + @Column() + idSidsem: string; + @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "idDosen" }) dosen: Pengguna; + + @Column() + idDosen: string; } diff --git a/src/helper/roles.ts b/src/helper/roles.ts index 690f29dd520df4c348a4520cfc75aee48540dca5..ebd5385ec59c130f5baf20939e9d5162ba647136 100644 --- a/src/helper/roles.ts +++ b/src/helper/roles.ts @@ -1,7 +1,12 @@ import { RoleEnum } from "src/entities/pengguna.entity"; export const HIGH_AUTHORITY_ROLES = [RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS]; +export const DOSEN = [RoleEnum.S2_PEMBIMBING, RoleEnum.S2_PENGUJI]; export function isHighAuthority(roles: RoleEnum[]) { return roles.some((role) => HIGH_AUTHORITY_ROLES.includes(role)); } + +export function isDosen(roles: RoleEnum[]) { + return roles.some((role) => DOSEN.includes(role)); +} diff --git a/src/main.ts b/src/main.ts index 8f39d1c33675cd4f871fe6271f9bfbd301979f3a..05e2e26f2ea78343514d74aec6917e2a357dad8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,8 +25,10 @@ async function bootstrap() { .addTag("Bimbingan") .addTag("Dashboard") .addTag("Dosen Bimbingan") + .addTag("Dosen Penguji") .addTag("Konfigurasi") .addTag("Registrasi Tesis") + .addTag("Registrasi Sidang Seminar") .addCookieAuth(process.env.COOKIE_NAME) .addBearerAuth() .build(); diff --git a/src/registrasi-sidsem/registrasi-sidsem.controller.ts b/src/registrasi-sidsem/registrasi-sidsem.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..15d95f95dc3c098631494a56e1d809598920df52 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.controller.ts @@ -0,0 +1,148 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { + CreatePengajuanSidsemDto, + GetAllPengajuanSidangReqQueryDto, + GetAllPengajuanSidangRespDto, + GetOnePengajuanSidangRespDto, + PengajuanSidsemIdDto, + SidsemMhsIdParamDto, + UpdateSidsemDetailDto, + UpdateSidsemStatusDto, +} from "./registrasi-sidsem.dto"; +import { RegistrasiSidsemService } from "./registrasi-sidsem.service"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { + DOSEN, + HIGH_AUTHORITY_ROLES, + isDosen, + isHighAuthority, +} from "src/helper/roles"; + +@ApiTags("Registrasi Sidang Seminar") +@ApiBearerAuth() +@ApiCookieAuth() +@UseGuards(CustomAuthGuard, RolesGuard) +@Controller("registrasi-sidsem") +export class RegistrasiSidsemController { + constructor(private readonly regisSidsemService: RegistrasiSidsemService) {} + + @ApiOperation({ + summary: "Create new sidang seminar registration. Roles: S2_MAHASISWA", + }) + @ApiCreatedResponse({ type: PengajuanSidsemIdDto }) + @Roles(RoleEnum.S2_MAHASISWA) + @Post() + async create(@Req() req: Request, @Body() dto: CreatePengajuanSidsemDto) { + const { id } = req.user as AuthDto; + return this.regisSidsemService.create(id, dto); + } + + @ApiOperation({ + summary: + "Get all newest sidang seminar registration per mhs. Roles: ADMIN, S2_TIM_TESIS, S2_PEMBIMBING, S2_PENGUJI", + }) + @ApiOkResponse({ type: GetAllPengajuanSidangRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, ...DOSEN) + @Get() + async findAll( + @Req() req: Request, + @Query() query: GetAllPengajuanSidangReqQueryDto, + ) { + const { id, roles } = req.user as AuthDto; + + if (!roles.includes(query.view)) { + throw new ForbiddenException(); + } + + return this.regisSidsemService.findAll( + query, + query.view === RoleEnum.S2_PEMBIMBING ? id : undefined, + query.view === RoleEnum.S2_PENGUJI ? id : undefined, + ); + } + + @ApiOperation({ + summary: + "Get newest sidang seminar registration per mhs. Roles: ADMIN, S2_TIM_TESIS, S2_PEMBIMBING, S2_PENGUJI, S2_MAHASISWA", + }) + @ApiOkResponse({ type: GetOnePengajuanSidangRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, ...DOSEN, RoleEnum.S2_MAHASISWA) + @Get("/mahasiswa/:mhsId") + async findOne(@Req() req: Request, @Param() param: SidsemMhsIdParamDto) { + let idPenguji = undefined; + let idPembimbing = undefined; + + const { roles, id } = req.user as AuthDto; + + if (!isHighAuthority(roles)) { + if (roles.includes(RoleEnum.S2_PEMBIMBING)) { + idPembimbing = id; + } + + if (roles.includes(RoleEnum.S2_PENGUJI)) { + idPenguji = id; + } + + if (!isDosen(roles) && id !== param.mhsId) { + // user is mahasiswa + throw new ForbiddenException("Ini bukan data Anda."); + } + } + return this.regisSidsemService.findOne( + param.mhsId, + idPembimbing, + idPenguji, + ); + } + + @ApiOperation({ + summary: "Update status sidang seminar. Roles: ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: PengajuanSidsemIdDto }) + @Roles(...HIGH_AUTHORITY_ROLES) + @Patch("/mahasiswa/:mhsId/status") + async updateStatus( + @Param() param: SidsemMhsIdParamDto, + @Body() updateDto: UpdateSidsemStatusDto, + ) { + return this.regisSidsemService.updateStatus(param.mhsId, updateDto.status); + } + + @ApiOperation({ + summary: + "Update detail of approved sidang seminar. Any falsify valued field will be ignored. Roles: ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: PengajuanSidsemIdDto }) + @Roles(...HIGH_AUTHORITY_ROLES) + @Patch("/mahasiswa/:mhsId/detail") + async updateDetail( + @Param() param: SidsemMhsIdParamDto, + @Body() updateDto: UpdateSidsemDetailDto, + ) { + return this.regisSidsemService.updateDetail(param.mhsId, updateDto); + } +} diff --git a/src/registrasi-sidsem/registrasi-sidsem.dto.ts b/src/registrasi-sidsem/registrasi-sidsem.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbead66ec4da85fd3d5ff7d5b537d1a8f873c361 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.dto.ts @@ -0,0 +1,177 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsDateString, + IsEnum, + IsNumberString, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { + PendaftaranSidsem, + SidsemStatus, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { JalurEnum } from "src/entities/pendaftaranTesis.entity"; +import { RoleEnum } from "src/entities/pengguna.entity"; + +export class SidsemViewQueryDto { + @IsEnum([ + RoleEnum.S2_PEMBIMBING, + RoleEnum.ADMIN, + RoleEnum.S2_TIM_TESIS, + RoleEnum.S2_PENGUJI, + ]) + @ApiProperty({ + enum: [ + RoleEnum.S2_PEMBIMBING, + RoleEnum.ADMIN, + RoleEnum.S2_TIM_TESIS, + RoleEnum.S2_PENGUJI, + ], + }) + view: + | RoleEnum.S2_PEMBIMBING + | RoleEnum.ADMIN + | RoleEnum.S2_TIM_TESIS + | RoleEnum.S2_PENGUJI; +} + +export class GetAllPengajuanSidangReqQueryDto extends SidsemViewQueryDto { + @ApiPropertyOptional() + @IsString() + @IsOptional() + search?: string; + + @ApiPropertyOptional({ enum: TipeSidsemEnum }) + @IsEnum(TipeSidsemEnum) + @IsOptional() + jenisSidang?: TipeSidsemEnum; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: no limit" }) + limit?: number; +} + +export class GetAllPengajuanSidangItemDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + idPengajuanSidsem: string; + + @ApiProperty() + idMahasiswa: string; + + @ApiProperty() + nimMahasiswa: string; + + @ApiProperty() + namaMahasiswa: string; + + @ApiProperty({ nullable: true }) + jadwalSidang: string | null; + + @ApiProperty({ enum: TipeSidsemEnum }) + jenisSidang: TipeSidsemEnum; + + @ApiProperty({ nullable: true }) + ruangan: string | null; + + @ApiProperty({ enum: SidsemStatus }) + status: SidsemStatus; + + @ApiProperty({ type: [String] }) + dosenPembimbing: string[]; + + @ApiProperty({ type: [BerkasSidsem] }) + berkasSidsem: BerkasSidsem[]; +} + +export class GetAllPengajuanSidangRespDto { + @ApiProperty() + total: number; + + @ApiProperty({ type: GetAllPengajuanSidangItemDto, isArray: true }) + data: GetAllPengajuanSidangItemDto[]; +} + +export class GetOnePengajuanSidangRespDto extends GetAllPengajuanSidangItemDto { + @ApiProperty() + emailMahasiswa: string; + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; + @ApiProperty() + judulTopik: string; + @ApiProperty() + deskripsiTopik: string; + @ApiProperty({ type: [String] }) + dosenPembimbing: string[]; + @ApiProperty({ type: [String] }) + dosenPenguji: string[]; + + @ApiProperty() + judulSidsem: string; + + @ApiProperty() + deskripsiSidsem: string; +} + +class BerkasSidsemWithoutId extends OmitType(BerkasSidsem, ["id"] as const) {} + +export class CreatePengajuanSidsemDto extends PickType(PendaftaranSidsem, [ + "judulSidsem", + "deskripsiSidsem", + "tipe", +]) { + @ApiProperty({ type: [BerkasSidsemWithoutId] }) + @ValidateNested({ each: true }) + @ArrayNotEmpty() + @Type(() => BerkasSidsemWithoutId) + berkasSidsem: BerkasSidsemWithoutId[]; +} + +export class PengajuanSidsemIdDto extends PickType(PendaftaranSidsem, [ + "id", +] as const) {} + +export class UpdateSidsemDetailDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + ruangan?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + jadwal?: Date; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID("all", { each: true }) + dosenPengujiIds?: string[]; +} + +export class SidsemMhsIdParamDto { + @IsUUID() + @ApiProperty() + mhsId: string; +} + +export class UpdateSidsemStatusDto { + @IsEnum([SidsemStatus.APPROVED, SidsemStatus.REJECTED]) + @ApiProperty({ enum: [SidsemStatus.APPROVED, SidsemStatus.REJECTED] }) + status: SidsemStatus.APPROVED | SidsemStatus.REJECTED; +} diff --git a/src/registrasi-sidsem/registrasi-sidsem.module.ts b/src/registrasi-sidsem/registrasi-sidsem.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c719e4bb91688835ae74a0deddff814efc15d31 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.module.ts @@ -0,0 +1,46 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuthModule } from "src/auth/auth.module"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { PendaftaranSidsem } from "src/entities/pendaftaranSidsem"; +import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { RegistrasiSidsemController } from "./registrasi-sidsem.controller"; +import { RegistrasiSidsemService } from "./registrasi-sidsem.service"; +import { RegistrasiTesisService } from "src/registrasi-tesis/registrasi-tesis.service"; +import { RegistrasiTesisModule } from "src/registrasi-tesis/registrasi-tesis.module"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { Topik } from "src/entities/topik.entity"; +import { PenggunaModule } from "src/pengguna/pengguna.module"; +import { PengujiSidsem } from "src/entities/pengujiSidsem.entity"; +import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { Konfigurasi } from "src/entities/konfigurasi.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + PendaftaranSidsem, + DosenBimbingan, + PendaftaranTesis, + Pengguna, + BerkasSidsem, + Topik, + PengujiSidsem, + Konfigurasi, + ]), + AuthModule, + RegistrasiTesisModule, + PenggunaModule, + KonfigurasiModule, + ], + controllers: [RegistrasiSidsemController], + providers: [ + RegistrasiSidsemService, + CustomStrategy, + RegistrasiTesisService, + KonfigurasiService, + ], +}) +export class RegistrasiSidsemModule {} diff --git a/src/registrasi-sidsem/registrasi-sidsem.service.ts b/src/registrasi-sidsem/registrasi-sidsem.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e900ba12a5115d0f3620dc763699b32309a69479 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.service.ts @@ -0,0 +1,453 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + cmpTipeSidsem, + PendaftaranSidsem, + SidsemStatus, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { PengujiSidsem } from "src/entities/pengujiSidsem.entity"; +import { DataSource, In, Repository, Brackets } from "typeorm"; +import { + CreatePengajuanSidsemDto, + GetAllPengajuanSidangItemDto, + GetAllPengajuanSidangReqQueryDto, + GetAllPengajuanSidangRespDto, + GetOnePengajuanSidangRespDto, + PengajuanSidsemIdDto, + UpdateSidsemDetailDto, +} from "./registrasi-sidsem.dto"; +import { RegStatus } from "src/entities/pendaftaranTesis.entity"; +import { RegistrasiTesisService } from "src/registrasi-tesis/registrasi-tesis.service"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { KonfigurasiKeyEnum } from "src/entities/konfigurasi.entity"; +import * as dayjs from "dayjs"; + +@Injectable() +export class RegistrasiSidsemService { + constructor( + @InjectRepository(PendaftaranSidsem) + private pendaftaranSidsemRepo: Repository<PendaftaranSidsem>, + @InjectRepository(PengujiSidsem) + private pengujiSidsemRepo: Repository<PengujiSidsem>, + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + @InjectRepository(BerkasSidsem) + private berkasSidsemRepo: Repository<BerkasSidsem>, + private regTesisService: RegistrasiTesisService, + private dataSource: DataSource, + private konfService: KonfigurasiService, + ) {} + + private async getLatestPendaftaranSidsem(mhsId: string) { + return await this.pendaftaranSidsemRepo + .createQueryBuilder("ps") + .select([ + "ps.id", + "ps.tipe", + "ps.jadwal", + "ps.ruangan", + "ps.status", + "ps.judulSidsem", + "ps.deskripsiSidsem", + "berkasSidsem", + "pt.id", + "pt.jalurPilihan", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "mahasiswa.email", + "topik.judul", + "topik.deskripsi", + "dosenBimbingan.id", + "dosen.id", + "dosen.nama", + "penguji.id", + "dosenPenguji.id", + "dosenPenguji.nama", + ]) + .leftJoin("ps.penguji", "penguji") + .leftJoin("ps.berkasSidsem", "berkasSidsem") + .leftJoin("penguji.dosen", "dosenPenguji") + .leftJoin("ps.pendaftaranTesis", "pt") + .leftJoin("pt.mahasiswa", "mahasiswa") + .leftJoin("pt.topik", "topik") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .where("pt.mahasiswaId = :mhsId", { mhsId }) + .andWhere("mahasiswa.aktif = true") + .orderBy("ps.waktuPengiriman", "DESC") + .getOne(); + } + + konfKeysMapping = { + [TipeSidsemEnum.SEMINAR_1]: { + start: KonfigurasiKeyEnum.AWAL_SEMPRO, + end: KonfigurasiKeyEnum.AKHIR_SEMPRO, + }, + [TipeSidsemEnum.SEMINAR_2]: { + start: KonfigurasiKeyEnum.AWAL_SEM_TESIS, + end: KonfigurasiKeyEnum.AKHIR_SEM_TESIS, + }, + [TipeSidsemEnum.SIDANG]: { + start: KonfigurasiKeyEnum.AWAL_SIDANG, + end: KonfigurasiKeyEnum.AKHIR_SIDANG, + }, + }; + + private async getSidsemKonfOrFail(tipe: TipeSidsemEnum) { + const mapping = this.konfKeysMapping[tipe]; + const [start, end] = await Promise.all([ + this.konfService.getKonfigurasiByKey(mapping.start), + this.konfService.getKonfigurasiByKey(mapping.end), + ]); + + if (!start || !end) { + throw new BadRequestException( + `Sidang seminar bertipe ${tipe} belum dikonfigurasi`, + ); + } + + return { start: new Date(start), end: new Date(end) }; + } + + async create( + mhsId: string, + dto: CreatePengajuanSidsemDto, + ): Promise<PengajuanSidsemIdDto> { + const { start, end } = await this.getSidsemKonfOrFail(dto.tipe); + + if ( + dayjs(new Date()).isBefore(dayjs(start).startOf("d")) || + dayjs(new Date()).isAfter(dayjs(end).endOf("d")) + ) { + throw new BadRequestException( + "Sidang seminar belum dibuka atau sudah ditutup", + ); + } + + const regTesis = await this.regTesisService.getNewestRegByMhsOrFail(mhsId); + + if (regTesis.status !== RegStatus.APPROVED) { + throw new BadRequestException( + "Mahasiswa belum diterima sebagai mahasiswa tesis.", + ); + } + + // Check if mahasiswa already has pending registration + const lastPendaftaran = await this.getLatestPendaftaranSidsem(mhsId); + if (lastPendaftaran) { + const delta = cmpTipeSidsem(dto.tipe, lastPendaftaran.tipe); + + if ( + (delta !== 0 && delta !== 1) || + (delta === 0 && lastPendaftaran.status !== SidsemStatus.REJECTED) || + (delta === 1 && lastPendaftaran.status !== SidsemStatus.APPROVED) + ) { + { + throw new BadRequestException("Tipe sidsem invalid"); + } + } + } else { + if (dto.tipe !== TipeSidsemEnum.SEMINAR_1) { + throw new BadRequestException("Tipe sidsem invalid"); + } + } + + const berkasSidsem = dto.berkasSidsem.map((berkasSubmisiTugas) => + this.berkasSidsemRepo.create(berkasSubmisiTugas), + ); + + // Create new registration + const createdRegistration = this.pendaftaranSidsemRepo.create({ + ...dto, + pendaftaranTesis: regTesis, + berkasSidsem, + }); + + await this.pendaftaranSidsemRepo.save(createdRegistration); + + return { + id: createdRegistration.id, + }; + } + + async findAll( + query: GetAllPengajuanSidangReqQueryDto, + idPembimbing?: string, + idPenguji?: string, + ): Promise<GetAllPengajuanSidangRespDto> { + const baseQuery = this.pendaftaranSidsemRepo + .createQueryBuilder("ps") + .select([ + "ps.id", + "ps.tipe", + "ps.status", + "pt.id", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "dosenBimbingan.id", + "dosen.id", + "dosen.nama", + "berkasSidsem", + ]) + .innerJoinAndSelect( + (qb) => + qb + .select([ + "ps.pendaftaranTesisId AS latest_pendaftaranTesisId", + "MAX(ps.waktuPengiriman) AS latestPengiriman", + ]) + .from(PendaftaranSidsem, "ps") + .groupBy("ps.pendaftaranTesisId"), + "latest", + "latest.latest_pendaftaranTesisId = ps.pendaftaranTesisId AND ps.waktuPengiriman = latest.latestPengiriman", + ) + .leftJoin("ps.pendaftaranTesis", "pt") + .leftJoin("ps.berkasSidsem", "berkasSidsem") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .leftJoin("pt.mahasiswa", "mahasiswa") + .where("mahasiswa.aktif = true") + .orderBy("ps.waktuPengiriman", "DESC"); + + if (idPembimbing) { + baseQuery + .innerJoin("pt.dosenBimbingan", "dosenBimbinganFilter") + .andWhere("dosenBimbinganFilter.idDosen = :idPembimbing", { + idPembimbing, + }); + } + + if (idPenguji) { + baseQuery + .innerJoin("ps.penguji", "pengujiFilter") + .andWhere("pengujiFilter.idDosen = :idPenguji", { + idPenguji, + }); + } + + if (query.search) { + baseQuery.andWhere( + new Brackets((qb) => + qb + .where("mahasiswa.nama ILIKE :search", { + search: `%${query.search}%`, + }) + .orWhere("mahasiswa.nim ILIKE :search", { + search: `%${query.search}%`, + }), + ), + ); + } + + if (query.jenisSidang) { + baseQuery.andWhere("ps.jenisSidang = :jenisSidang", { + jenisSidang: query.jenisSidang, + }); + } + + if (query.limit) { + baseQuery.take(query.limit); + baseQuery.skip((query.page - 1) * query.limit); + } + + const [queryData, total] = await baseQuery.getManyAndCount(); + + const data: GetAllPengajuanSidangItemDto[] = queryData.map((res) => ({ + idPengajuanSidsem: res.id, + idMahasiswa: res.pendaftaranTesis.mahasiswa.id, + nimMahasiswa: res.pendaftaranTesis.mahasiswa.nim, + namaMahasiswa: res.pendaftaranTesis.mahasiswa.nama, + jadwalSidang: !!res.jadwal ? res.jadwal.toISOString() : null, + jenisSidang: res.tipe, + ruangan: res.ruangan, + status: res.status, + dosenPembimbing: res.pendaftaranTesis.dosenBimbingan.map( + (dosen) => dosen.dosen.nama, + ), + berkasSidsem: res.berkasSidsem, + })); + + return { data, total }; + } + + async findOne( + mhsId: string, + idPembimbing?: string, + idPenguji?: string, + ): Promise<GetOnePengajuanSidangRespDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest) { + throw new NotFoundException("Pendaftaran sidsem tidak ditemukan"); + } + + function isPembimbing() { + return latest.pendaftaranTesis.dosenBimbingan.some( + ({ dosen: { id } }) => id === idPembimbing, + ); + } + + function isPenguji() { + return latest.penguji.some(({ dosen: { id } }) => id === idPenguji); + } + + if (idPembimbing && idPenguji) { + if (!isPembimbing() && !isPenguji()) { + throw new ForbiddenException( + "Anda tidak terdaftar sebagai pembimbing atau penguji", + ); + } + } else if (idPembimbing) { + if (!isPembimbing()) { + throw new ForbiddenException("Anda tidak terdaftar sebagai pembimbing"); + } + } else if (idPenguji) { + if (!isPenguji()) { + throw new ForbiddenException("Anda tidak terdaftar sebagai penguji"); + } + } + + const data: GetOnePengajuanSidangRespDto = { + idPengajuanSidsem: latest.id, + idMahasiswa: latest.pendaftaranTesis.mahasiswa.id, + nimMahasiswa: latest.pendaftaranTesis.mahasiswa.nim, + namaMahasiswa: latest.pendaftaranTesis.mahasiswa.nama, + emailMahasiswa: latest.pendaftaranTesis.mahasiswa.email, + jadwalSidang: latest.jadwal ? latest.jadwal.toISOString() : null, + jenisSidang: latest.tipe, + ruangan: latest.ruangan, + jalurPilihan: latest.pendaftaranTesis.jalurPilihan, + judulTopik: latest.pendaftaranTesis.topik.judul, + deskripsiTopik: latest.pendaftaranTesis.topik.deskripsi, + status: latest.status, + berkasSidsem: latest.berkasSidsem, + judulSidsem: latest.judulSidsem, + deskripsiSidsem: latest.deskripsiSidsem, + dosenPembimbing: latest.pendaftaranTesis.dosenBimbingan.map( + ({ dosen: { nama } }) => nama, + ), + dosenPenguji: latest.penguji.map(({ dosen: { nama } }) => nama), + }; + + return data; + } + + async updateStatus( + mhsId: string, + status: SidsemStatus.REJECTED | SidsemStatus.APPROVED, + ): Promise<PengajuanSidsemIdDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest || latest.status !== SidsemStatus.NOT_ASSIGNED) { + throw new BadRequestException( + "Pendaftaran sidsem yang pending tidak ditemukan", + ); + } + + await this.pendaftaranSidsemRepo.update(latest.id, { + status, + }); + + return { id: latest.id } as PengajuanSidsemIdDto; + } + + async updateDetail( + mhsId: string, + updateDto: UpdateSidsemDetailDto, + ): Promise<PengajuanSidsemIdDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest || latest.status !== SidsemStatus.APPROVED) { + throw new BadRequestException( + "Pendaftaran sidsem yang disetujui tidak ditemukan", + ); + } + + if (updateDto.jadwal) { + if (dayjs(updateDto.jadwal).isBefore(dayjs(new Date()).endOf("d"))) { + throw new BadRequestException("Jadwal sidang tidak valid"); + } + } + + if (updateDto.dosenPengujiIds) { + const newPengujiList = await this.penggunaRepo.findBy({ + id: In(updateDto.dosenPengujiIds), + }); + + if ( + newPengujiList.length !== updateDto.dosenPengujiIds.length || + newPengujiList.some( + (dosen) => !dosen.roles.includes(RoleEnum.S2_PENGUJI), + ) + ) + throw new BadRequestException( + "Dosen id list contains invalid user ids", + ); + + const currentPenguji = await this.pengujiSidsemRepo.findBy({ + idSidsem: latest.id, + }); + + const newPengujiIds = newPengujiList.map((dosen) => dosen.id); + const currentPengujiIds = currentPenguji.map( + (currentPembimbing) => currentPembimbing.idDosen, + ); + + const idsToBeAdded = newPengujiIds.filter( + (newId) => !currentPengujiIds.includes(newId), + ); + + const idsToBeDeleted = currentPengujiIds.filter( + (newId) => !newPengujiIds.includes(newId), + ); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.insert( + PengujiSidsem, + idsToBeAdded.map((idDosen) => ({ sidsem: latest, idDosen })), + ); + await queryRunner.manager.delete(PengujiSidsem, { + idDosen: In(idsToBeDeleted), + }); + + if (updateDto.ruangan || updateDto.jadwal) { + await queryRunner.manager.update(PendaftaranSidsem, latest.id, { + ruangan: updateDto.ruangan, + jadwal: updateDto.jadwal, + }); + } + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + console.error(err); + + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } else { + await this.pendaftaranSidsemRepo.update(latest.id, { + ...updateDto, + }); + } + + return { id: latest.id } as PengajuanSidsemIdDto; + } +} diff --git a/src/registrasi-tesis/registrasi-tesis.module.ts b/src/registrasi-tesis/registrasi-tesis.module.ts index 0b64d44cec70b955ad05a008eedd82f63760e1c3..c3bbd21aa0fd1935e94879632141698224e27ac3 100644 --- a/src/registrasi-tesis/registrasi-tesis.module.ts +++ b/src/registrasi-tesis/registrasi-tesis.module.ts @@ -24,5 +24,6 @@ import { PenggunaService } from "src/pengguna/pengguna.service"; ], controllers: [RegistrasiTesisController], providers: [RegistrasiTesisService, CustomStrategy, PenggunaService], + exports: [RegistrasiTesisService], }) export class RegistrasiTesisModule {} diff --git a/src/registrasi-tesis/registrasi-tesis.service.ts b/src/registrasi-tesis/registrasi-tesis.service.ts index 17e59c6096fcf7f65aff48a21dda444938e8515e..4efe8d5d5be8bc2b1bb94cab81c1da1381d473aa 100644 --- a/src/registrasi-tesis/registrasi-tesis.service.ts +++ b/src/registrasi-tesis/registrasi-tesis.service.ts @@ -373,7 +373,7 @@ export class RegistrasiTesisService { return resData; } - private async getNewestRegByMhsOrFail(mahasiswaId: string) { + async getNewestRegByMhsOrFail(mahasiswaId: string) { const mahasiswa = await this.penggunaRepository.findOne({ select: { id: true, @@ -381,11 +381,14 @@ export class RegistrasiTesisService { }, where: { id: mahasiswaId, + aktif: true, }, }); if (!mahasiswa || !mahasiswa.roles.includes(RoleEnum.S2_MAHASISWA)) - throw new BadRequestException("No mahasiswa user with given id exists"); + throw new BadRequestException( + "No active mahasiswa user with given id exists", + ); const newestReg = await this.pendaftaranTesisRepository.findOne({ select: {