diff --git a/src/app.module.ts b/src/app.module.ts index 7d86d78321e38b5e7ee89251508aa951410c824d..e2718653c5b8f7856d0baf4d258e4cacf378060b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,22 +4,16 @@ import { AppService } from "./app.service"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Bimbingan } from "./entities/bimbingan.entity"; import { Pengguna } from "./entities/pengguna.entity"; -import { RangeJadwalSeminar } from "./entities/rangeJadwalSeminar.entity"; -import { Seminar } from "./entities/seminar.entity"; import { Topik } from "./entities/topik.entity"; import { AuditLog } from "./entities/auditLog.entity"; import { DosenBimbingan } from "./entities/dosenBimbingan.entity"; import { Kelas } from "./entities/kelas.entity"; -import { MahasiswaKelas } from "./entities/mahasiswaKelas"; +import { MahasiswaKelas } from "./entities/mahasiswaKelas.entity"; import { PengajarKelas } from "./entities/pengajarKelas.entity"; import { PendaftaranTesis } from "./entities/pendaftaranTesis.entity"; -import { RangeJadwalSidang } from "./entities/rangeJadwalSidang.entity"; -import { Ruangan } from "./entities/ruangan.entity"; -import { Sidang } from "./entities/sidang.entity"; +// import { Ruangan } from "./entities/ruangan.entity"; import { Tugas } from "./entities/tugas.entity"; -import { PembimbingSeminar } from "./entities/pembimbingSeminar.entity"; -import { PembimbingSidang } from "./entities/pembimbingSidang.entity"; -import { PengujiSidang } from "./entities/pengujiSidang.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"; @@ -28,14 +22,18 @@ 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 { validate } from "./env.validation"; -import { MataKuliah } from "./entities/mataKuliah"; -import { SubmisiTugas } from "./entities/submisiTugas"; +import { BerkasBimbingan } from "./entities/berkasBimbingan.entity"; +import { MataKuliah } from "./entities/mataKuliah.entity"; +import { SubmisiTugas } from "./entities/submisiTugas.entity"; +import { BerkasSubmisiTugas } from "./entities/berkasSubmisiTugas.entity"; +import { BerkasTugas } from "./entities/berkasTugas.entity"; +import { TugasModule } from "./tugas/tugas.module"; import { KelasModule } from "./kelas/kelas.module"; -import { BerkasSubmisiTugas } from "./entities/berkasSubmisiTugas"; -import { BerkasTugas } from "./entities/berkasTugas"; +import { SubmisiTugasModule } from "./submisi-tugas/submisi-tugas.module"; +import { NilaiModule } from "./nilai/nilai.module"; +import { PendaftaranSidsem } from "./entities/pendaftaranSidsem"; +import { DosenBimbinganModule } from "./dosen-bimbingan/dosen-bimbingan.module"; @Module({ imports: [ @@ -49,10 +47,10 @@ import { BerkasTugas } from "./entities/berkasTugas"; database: process.env.POSTGRES_DATABASE, ssl: process.env.POSTGRES_HOST !== "localhost", entities: [ + BerkasBimbingan, Bimbingan, Pengguna, - RangeJadwalSeminar, - Seminar, + PendaftaranSidsem, Topik, AuditLog, DosenBimbingan, @@ -60,13 +58,9 @@ import { BerkasTugas } from "./entities/berkasTugas"; MahasiswaKelas, PengajarKelas, PendaftaranTesis, - RangeJadwalSidang, - Ruangan, - Sidang, + // Ruangan, Tugas, - PembimbingSeminar, - PembimbingSidang, - PengujiSidang, + PengujiSidsem, Konfigurasi, MataKuliah, SubmisiTugas, @@ -81,9 +75,11 @@ import { BerkasTugas } from "./entities/berkasTugas"; DashboardModule, BimbinganModule, KonfigurasiModule, - DosenBimbinganModule, - ApprovalModule, KelasModule, + TugasModule, + SubmisiTugasModule, + NilaiModule, + DosenBimbinganModule, ], controllers: [AppController], providers: [AppService], 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/bimbingan/bimbingan.controller.ts b/src/bimbingan/bimbingan.controller.ts index c7c7f678c42446ed9f1fe518eccb7e8320359daf..99eab5d006d76622b2d8cf3a7699c41f93e4ead2 100644 --- a/src/bimbingan/bimbingan.controller.ts +++ b/src/bimbingan/bimbingan.controller.ts @@ -3,14 +3,18 @@ import { Controller, Get, Param, + Patch, Post, Req, UseGuards, } from "@nestjs/common"; import { + ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiCookieAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, ApiOkResponse, ApiResponse, ApiTags, @@ -25,7 +29,10 @@ import { ByMhsIdDto, CreateBimbinganReqDto, CreateBimbinganResDto, + GetByBimbinganIdResDto, GetByMahasiswaIdResDto, + UpdateStatusDto, + UpdateStatusResDto, } from "./bimbingan.dto"; import { BimbinganService } from "./bimbingan.service"; @@ -38,8 +45,11 @@ export class BimbinganController { constructor(private readonly bimbinganService: BimbinganService) {} @ApiOkResponse({ type: GetByMahasiswaIdResDto }) + @ApiNotFoundResponse({ + description: "Tidak ada pendaftaran pada periode sekarang", + }) @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) - @Get("/:mahasiswaId") + @Get("/mahasiswa/:mahasiswaId") async getByMahasiswaId( @Param() param: ByMhsIdDto, @Req() request: Request, @@ -51,6 +61,9 @@ export class BimbinganController { } @ApiOkResponse({ type: GetByMahasiswaIdResDto }) + @ApiNotFoundResponse({ + description: "Tidak ada pendaftaran pada periode sekarang", + }) @Roles(RoleEnum.S2_MAHASISWA) @Get("/") async getOwnBimbingan( @@ -62,15 +75,54 @@ export class BimbinganController { ); } - // TODO handle file upload @ApiResponse({ status: 201, type: CreateBimbinganResDto }) + @ApiBadRequestResponse({ + description: + "Waktu bimbingan lebih dari hari ini atau bimbingan berikutnya sebelum waktu bimbingan yang dimasukkan", + }) + @ApiNotFoundResponse({ + description: "Tidak ada pendaftaran pada periode sekarang", + }) @Roles(RoleEnum.S2_MAHASISWA) @ApiBody({ type: CreateBimbinganReqDto }) @Post("/") - async getBimbinganLogs( + async createBimbinganLog( @Req() request: Request, @Body() body: CreateBimbinganReqDto, ): Promise<CreateBimbinganResDto> { return this.bimbinganService.create((request.user as AuthDto).id, body); } + + @ApiOkResponse({ type: UpdateStatusResDto }) + @ApiNotFoundResponse({ + description: "Bimbingan tidak ditemukan", + }) + @ApiForbiddenResponse({ + description: "Anda tidak memiliki akses untuk mengubah status bimbingan", + }) + @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN) + @ApiBody({ type: UpdateStatusDto }) + @Patch("/pengesahan") + async updateStatus( + @Req() request: Request, + @Body() body: UpdateStatusDto, + ): Promise<UpdateStatusResDto> { + return this.bimbinganService.updateStatus(request.user as AuthDto, body); + } + + @ApiOkResponse({ type: GetByBimbinganIdResDto }) + @ApiNotFoundResponse({ + description: "Bimbingan tidak ditemukan", + }) + @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN) + @Get("/:bimbinganId") + async getByBimbinganId( + @Req() request: Request, + @Param("bimbinganId") bimbinganId: string, + ): Promise<GetByBimbinganIdResDto> { + return this.bimbinganService.getByBimbinganId( + request.user as AuthDto, + bimbinganId, + ); + } } diff --git a/src/bimbingan/bimbingan.dto.ts b/src/bimbingan/bimbingan.dto.ts index 3d41ff0017cd71e2cd996908671baea4809107b2..f3afcdac8aeeb0380633e5609bc7b35d8c864013 100644 --- a/src/bimbingan/bimbingan.dto.ts +++ b/src/bimbingan/bimbingan.dto.ts @@ -1,7 +1,25 @@ -import { IsDateString, IsString } from "@nestjs/class-validator"; -import { ApiProperty, PickType } from "@nestjs/swagger"; -import { Bimbingan } from "src/entities/bimbingan.entity"; -import { JalurEnum } from "src/entities/pendaftaranTesis.entity"; +import { + IsBoolean, + IsDateString, + IsDefined, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "@nestjs/class-validator"; +import { + ApiProperty, + IntersectionType, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; +import { Bimbingan, BimbinganStatus } from "src/entities/bimbingan.entity"; +import { + JalurEnum, + PendaftaranTesis, +} from "src/entities/pendaftaranTesis.entity"; import { Pengguna } from "src/entities/pengguna.entity"; import { Topik } from "src/entities/topik.entity"; @@ -27,8 +45,13 @@ export class GetByMahasiswaIdResDto { @ApiProperty({ type: PickedTopikBimbingan }) topik: PickedTopikBimbingan; + + @ApiProperty({ enum: BimbinganStatus }) + status: BimbinganStatus; } +class BerkasWithoutId extends OmitType(BerkasBimbingan, ["id"] as const) {} + export class CreateBimbinganReqDto { @ApiProperty({ type: Date }) @IsDateString() @@ -42,19 +65,45 @@ export class CreateBimbinganReqDto { @IsString() todo: string; - // TODO file upload - @ApiProperty({ type: Date }) @IsDateString() + @IsOptional() bimbinganBerikutnya: string; + + @ApiProperty({ type: [BerkasWithoutId] }) + @ValidateNested({ each: true }) + @Type(() => BerkasWithoutId) + @IsDefined() + berkas: BerkasWithoutId[]; } export class CreateBimbinganResDto { @ApiProperty() - message: string; + id: string; } export class ByMhsIdDto { @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() mahasiswaId: string; } + +export class UpdateStatusDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + bimbinganId: string; + + @ApiProperty() + @IsBoolean() + status: boolean; +} + +export class UpdateStatusResDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + id: string; +} + +export class GetByBimbinganIdResDto extends IntersectionType( + OmitType(Bimbingan, ["pendaftaran"] as const), + PickType(PendaftaranTesis, ["jalurPilihan"] as const), +) {} diff --git a/src/bimbingan/bimbingan.module.ts b/src/bimbingan/bimbingan.module.ts index 7a8482ad25d69326a57905cfa0ab927048f7caed..30174c69ff5a33b4a5caa742095ef9f830c5eb8d 100644 --- a/src/bimbingan/bimbingan.module.ts +++ b/src/bimbingan/bimbingan.module.ts @@ -6,6 +6,7 @@ import { Bimbingan } from "src/entities/bimbingan.entity"; import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; import { Konfigurasi } from "src/entities/konfigurasi.entity"; import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; @Module({ imports: [ @@ -14,9 +15,11 @@ import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; PendaftaranTesis, Konfigurasi, DosenBimbingan, + BerkasBimbingan, ]), ], controllers: [BimbinganController], providers: [BimbinganService], + exports: [BimbinganService], }) export class BimbinganModule {} diff --git a/src/bimbingan/bimbingan.service.ts b/src/bimbingan/bimbingan.service.ts index b1e047a39b5ab5a6b06e5db6206dbc0af3159a28..52c1ba28e98f9c71aad2d88e5ebeafb8d1cb1d40 100644 --- a/src/bimbingan/bimbingan.service.ts +++ b/src/bimbingan/bimbingan.service.ts @@ -7,7 +7,7 @@ import { import { InjectRepository } from "@nestjs/typeorm"; import * as dayjs from "dayjs"; import { AuthDto } from "src/auth/auth.dto"; -import { Bimbingan } from "src/entities/bimbingan.entity"; +import { Bimbingan, BimbinganStatus } from "src/entities/bimbingan.entity"; import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; import { Konfigurasi } from "src/entities/konfigurasi.entity"; import { @@ -19,8 +19,12 @@ import { Repository } from "typeorm"; import { CreateBimbinganReqDto, CreateBimbinganResDto, + GetByBimbinganIdResDto, GetByMahasiswaIdResDto, + UpdateStatusDto, + UpdateStatusResDto, } from "./bimbingan.dto"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; @Injectable() export class BimbinganService { @@ -33,6 +37,8 @@ export class BimbinganService { private konfigurasiRepository: Repository<Konfigurasi>, @InjectRepository(DosenBimbingan) private dosenBimbinganRepository: Repository<DosenBimbingan>, + @InjectRepository(BerkasBimbingan) + private berkasBimbinganRepository: Repository<BerkasBimbingan>, ) {} async getByMahasiswaId( @@ -88,8 +94,13 @@ export class BimbinganService { id: pendaftaran.id, }, }, + relations: { + berkas: true, + }, }); + const status = await this.getBimbinganStatus(pendaftaran); + return { bimbingan, mahasiswa: { @@ -99,10 +110,10 @@ export class BimbinganService { jalurPilihan: pendaftaran.jalurPilihan, }, topik: pendaftaran.topik, + status, }; } - // TODO handle file upload async create( mahasiswaId: string, createDto: CreateBimbinganReqDto, @@ -133,8 +144,6 @@ export class BimbinganService { ); } - console.log(dayjs(createDto.waktuBimbingan).toDate()); - if (dayjs(createDto.waktuBimbingan).isAfter(dayjs(new Date()).endOf("d"))) throw new BadRequestException( "Tanggal bimbingan yang dimasukkan tidak boleh melebihi tanggal hari ini", @@ -149,14 +158,116 @@ export class BimbinganService { "Bimbingan berikutnya harus setelah bimbingan yang dimasukkan", ); + const berkasBimbingan = createDto.berkas.map((berkas) => + this.berkasBimbinganRepository.create(berkas), + ); + const createdBimbinganLog = this.bimbinganRepository.create({ - ...createDto, - berkasLinks: [], + waktuBimbingan: createDto.waktuBimbingan, + laporanKemajuan: createDto.laporanKemajuan, + todo: createDto.todo, + bimbinganBerikutnya: createDto.bimbinganBerikutnya, + berkas: berkasBimbingan, pendaftaran, }); await this.bimbinganRepository.save(createdBimbinganLog); - return { message: "Successfully added log" }; + return { id: createdBimbinganLog.id }; + } + + async updateStatus( + user: AuthDto, + dto: UpdateStatusDto, + ): Promise<UpdateStatusResDto> { + const bimbingan = await this.getByBimbinganId(user, dto.bimbinganId); + + await this.bimbinganRepository.update(bimbingan.id, { + disahkan: dto.status, + }); + + return { + id: bimbingan.id, + }; + } + + async getByBimbinganId( + user: AuthDto, + bimbinganId: string, + ): Promise<GetByBimbinganIdResDto> { + const currentPeriode = await this.konfigurasiRepository.findOne({ + where: { key: process.env.KONF_PERIODE_KEY }, + }); + + const bimbinganQuery = this.bimbinganRepository + .createQueryBuilder("bimbingan") + .leftJoinAndSelect("bimbingan.pendaftaran", "pendaftaran") + .leftJoinAndSelect("pendaftaran.dosenBimbingan", "dosenBimbingan") + .leftJoinAndSelect("dosenBimbingan.dosen", "dosen") + .leftJoinAndSelect("bimbingan.berkas", "berkas") + .leftJoin("pendaftaran.topik", "topik", "topik.periode = :periode", { + periode: currentPeriode.value, + }) + .leftJoinAndSelect("pendaftaran.mahasiswa", "mahasiswa") + .where("bimbingan.id = :id", { id: bimbinganId }); + const bimbingan = await bimbinganQuery.getOne(); + + if (!bimbingan) { + throw new NotFoundException("Bimbingan tidak ditemukan"); + } + + if ( + !user.roles.includes(RoleEnum.ADMIN) && + !bimbingan.pendaftaran.dosenBimbingan + .map((d) => d.dosen.id) + .includes(user.id) + ) { + throw new ForbiddenException(); + } + + return { + id: bimbingan.id, + waktuBimbingan: bimbingan.waktuBimbingan, + laporanKemajuan: bimbingan.laporanKemajuan, + todo: bimbingan.todo, + bimbinganBerikutnya: bimbingan.bimbinganBerikutnya, + disahkan: bimbingan.disahkan, + berkas: bimbingan.berkas, + jalurPilihan: bimbingan.pendaftaran.jalurPilihan, + }; + } + + async getBimbinganStatus( + pendaftaran: PendaftaranTesis, + ): Promise<BimbinganStatus> { + const lastBimbinganQuery = this.bimbinganRepository + .createQueryBuilder("bimbingan") + .where("bimbingan.pendaftaranId = :pendaftaranId", { + pendaftaranId: pendaftaran.id, + }) + .orderBy("bimbingan.waktuBimbingan", "DESC") + .limit(1); + + const lastBimbingan = await lastBimbinganQuery.getOne(); + + if (!lastBimbingan) { + return dayjs(pendaftaran.waktuPengiriman).isBefore( + dayjs().subtract(3, "month"), + ) + ? BimbinganStatus.TERKENDALA + : BimbinganStatus.LANCAR; + } + + if ( + dayjs(lastBimbingan.waktuBimbingan).isBefore(dayjs().subtract(3, "month")) + ) { + return BimbinganStatus.TERKENDALA; + } else if ( + dayjs(lastBimbingan.waktuBimbingan).isBefore(dayjs().subtract(1, "month")) + ) { + return BimbinganStatus.BUTUH_BIMBINGAN; + } else { + return BimbinganStatus.LANCAR; + } } } diff --git a/src/dashboard/dashboard.controller.ts b/src/dashboard/dashboard.controller.ts index 0da85958efd66d633cb5051480213703629e9cd1..31a3a4cc1dc88afe12ba4b05e16bc015fd2a41e9 100644 --- a/src/dashboard/dashboard.controller.ts +++ b/src/dashboard/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Req, UseGuards } from "@nestjs/common"; +import { Controller, Get, Query, Req, UseGuards } from "@nestjs/common"; import { DashboardService } from "./dashboard.service"; import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; import { RolesGuard } from "src/middlewares/roles.guard"; @@ -6,7 +6,12 @@ import { RoleEnum } from "src/entities/pengguna.entity"; import { Roles } from "src/middlewares/roles.decorator"; import { AuthDto } from "src/auth/auth.dto"; import { Request } from "express"; -import { DashboardDto, JalurStatisticDto } from "./dashboard.dto"; +import { + DashboardDto, + DashboardMahasiswaResDto, + GetDashboardDosbimQueryDto, + JalurStatisticDto, +} from "./dashboard.dto"; import { ApiBearerAuth, ApiCookieAuth, @@ -25,8 +30,14 @@ export class DashboardController { @ApiOkResponse({ type: [DashboardDto] }) @Roles(RoleEnum.S2_PEMBIMBING) @Get("/dosbim") - async findByPenerimaId(@Req() request: Request): Promise<DashboardDto[]> { - return this.dashboardService.findByPenerimaId((request.user as AuthDto).id); + async findByPenerimaId( + @Req() request: Request, + @Query() query: GetDashboardDosbimQueryDto, + ): Promise<DashboardDto[]> { + return this.dashboardService.findByDosenId( + (request.user as AuthDto).id, + query.search, + ); } @ApiOkResponse({ type: [JalurStatisticDto] }) @@ -37,4 +48,16 @@ export class DashboardController { (request.user as AuthDto).id, ); } + + @UseGuards(CustomAuthGuard, RolesGuard) + @Roles(RoleEnum.S2_MAHASISWA) + @ApiOkResponse({ type: DashboardMahasiswaResDto }) + @Get("/mahasiswa") + async getDashboardMahasiswa( + @Req() request: Request, + ): Promise<DashboardMahasiswaResDto> { + return this.dashboardService.getDashboardMahasiswa( + (request.user as AuthDto).id, + ); + } } diff --git a/src/dashboard/dashboard.dto.ts b/src/dashboard/dashboard.dto.ts index 39ec3e41504d87deb6faf1cb44e35376f63ad8ad..8d279937d0616eb007400145e622316c5d4b9012 100644 --- a/src/dashboard/dashboard.dto.ts +++ b/src/dashboard/dashboard.dto.ts @@ -1,15 +1,57 @@ -import { ApiProperty, PickType } from "@nestjs/swagger"; -import { JalurEnum } from "../entities/pendaftaranTesis.entity"; +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { + JalurEnum, + PendaftaranTesis, +} from "../entities/pendaftaranTesis.entity"; import { Topik } from "src/entities/topik.entity"; import { Pengguna } from "src/entities/pengguna.entity"; +import { Bimbingan } from "src/entities/bimbingan.entity"; +import { PendaftaranSidsem } from "src/entities/pendaftaranSidsem"; +import { IsOptional } from "class-validator"; +import { BimbinganStatus } from "src/entities/bimbingan.entity"; class PickedTopikDashboard extends PickType(Topik, ["id", "judul"] as const) {} class PickedMhsDashboard extends PickType(Pengguna, [ "id", "nama", "nim", + "email", ] as const) {} +class OmittedTopikMhsDashboard extends OmitType(Topik, ["pengaju"] as const) {} + +class NoEmailUserDashboard extends OmitType(PickedMhsDashboard, [ + "email", +] as const) {} + +export class NoNIMUserDashboard extends OmitType(PickedMhsDashboard, [ + "nim", +] as const) {} + +class OmittedPendaftaranTesisMhsDashboard extends OmitType(PendaftaranTesis, [ + "mahasiswa", + "topik", + "penerima", +] as const) { + @ApiProperty() + topik: OmittedTopikMhsDashboard; + + @ApiProperty() + penerima: NoEmailUserDashboard; +} + +class SidsemWithPenguji extends OmitType(PendaftaranSidsem, [ + "penguji", +] as const) { + @ApiProperty({ type: [NoNIMUserDashboard] }) + penguji: NoNIMUserDashboard[]; +} + export class DashboardDto { @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) id: string; @@ -17,14 +59,14 @@ export class DashboardDto { @ApiProperty({ enum: JalurEnum }) jalurPilihan: JalurEnum; - @ApiProperty() - status: string; + @ApiProperty({ enum: BimbinganStatus }) + status: BimbinganStatus; @ApiProperty() topik: PickedTopikDashboard; @ApiProperty() - mahasiswa: PickedMhsDashboard; + mahasiswa: NoEmailUserDashboard; } export class JalurStatisticDto { @@ -34,3 +76,41 @@ export class JalurStatisticDto { @ApiProperty() count: number; } + +export class DashboardMahasiswaResDto { + @ApiProperty() + mahasiswa: PickedMhsDashboard; + + @ApiProperty({ type: OmittedPendaftaranTesisMhsDashboard, nullable: true }) + pendaftaranTesis: OmittedPendaftaranTesisMhsDashboard; + + @ApiProperty({ type: [NoNIMUserDashboard] }) + dosenBimbingan: NoNIMUserDashboard[]; + + @ApiProperty({ type: [Bimbingan] }) + bimbingan: Bimbingan[]; + + @ApiProperty({ + type: PendaftaranSidsem, + nullable: true, + }) + seminarSatu: PendaftaranSidsem; + + @ApiProperty({ + type: SidsemWithPenguji, + nullable: true, + }) + seminarDua: SidsemWithPenguji; + + @ApiProperty({ + type: SidsemWithPenguji, + nullable: true, + }) + sidang: SidsemWithPenguji; +} + +export class GetDashboardDosbimQueryDto { + @ApiPropertyOptional({}) + @IsOptional() + search: string; +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts index e9f3d361fb9237419b3ba47e7ac835a3d0fab6f6..8f8f447d98f6ce5c3d40de228881aa244d364bba 100644 --- a/src/dashboard/dashboard.module.ts +++ b/src/dashboard/dashboard.module.ts @@ -6,10 +6,23 @@ import { PendaftaranTesis } from "../entities/pendaftaranTesis.entity"; import { Pengguna } from "../entities/pengguna.entity"; import { Topik } from "../entities/topik.entity"; import { Konfigurasi } from "src/entities/konfigurasi.entity"; +import { Bimbingan } from "src/entities/bimbingan.entity"; +import { PendaftaranSidsem } from "src/entities/pendaftaranSidsem"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { BimbinganModule } from "src/bimbingan/bimbingan.module"; @Module({ imports: [ - TypeOrmModule.forFeature([PendaftaranTesis, Pengguna, Topik, Konfigurasi]), + TypeOrmModule.forFeature([ + PendaftaranTesis, + Pengguna, + Topik, + Konfigurasi, + Bimbingan, + PendaftaranSidsem, + DosenBimbingan, + ]), + BimbinganModule, ], controllers: [DashboardController], providers: [DashboardService], diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts index 19b4ff4b924bfad717bff5498eab582daa5c91ae..c2bfcb12e4e7c8bbcbfdfacf6b77a224c475a5b1 100644 --- a/src/dashboard/dashboard.service.ts +++ b/src/dashboard/dashboard.service.ts @@ -1,13 +1,25 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Brackets, Repository } from "typeorm"; import { PendaftaranTesis, RegStatus, } from "../entities/pendaftaranTesis.entity"; import { Pengguna } from "../entities/pengguna.entity"; import { Konfigurasi } from "src/entities/konfigurasi.entity"; -import { DashboardDto, JalurStatisticDto } from "./dashboard.dto"; +import { Bimbingan } from "src/entities/bimbingan.entity"; +import { + DashboardDto, + DashboardMahasiswaResDto, + JalurStatisticDto, + NoNIMUserDashboard, +} from "./dashboard.dto"; +import { + PendaftaranSidsem, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { BimbinganService } from "src/bimbingan/bimbingan.service"; @Injectable() export class DashboardService { @@ -18,6 +30,13 @@ export class DashboardService { private penggunaRepository: Repository<Pengguna>, @InjectRepository(Konfigurasi) private konfigurasiRepository: Repository<Konfigurasi>, + @InjectRepository(Bimbingan) + private bimbinganRepository: Repository<Bimbingan>, + @InjectRepository(PendaftaranSidsem) + private pendaftaranSidsemRepository: Repository<PendaftaranSidsem>, + @InjectRepository(DosenBimbingan) + private dosenBimbinganRepository: Repository<DosenBimbingan>, + private bimbinganService: BimbinganService, ) {} async findAll(): Promise<PendaftaranTesis[]> { @@ -26,73 +45,229 @@ export class DashboardService { }); } - async findByPenerimaId(penerimaId: string): Promise<DashboardDto[]> { + async findByDosenId( + dosenId: string, + search?: string, + ): Promise<DashboardDto[]> { const currentPeriode = await this.konfigurasiRepository.findOne({ where: { key: process.env.KONF_PERIODE_KEY }, }); - const pendaftaranTesis = await this.pendaftaranTesisRepository.find({ - where: { - status: RegStatus.APPROVED, - penerima: { - id: penerimaId, + if (!currentPeriode) { + throw new BadRequestException("Periode belum dikonfigurasi"); + } + + let pendaftaranTesisQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pendaftaranTesis") + .leftJoinAndSelect("pendaftaranTesis.mahasiswa", "mahasiswa") + .leftJoinAndSelect("pendaftaranTesis.topik", "topik") + .innerJoin( + "pendaftaranTesis.dosenBimbingan", + "dosenBimbingan", + "dosenBimbingan.idDosen = :dosenId", + { + dosenId, }, + ) + .andWhere("pendaftaranTesis.status = :status", { + status: RegStatus.APPROVED, + }) + .andWhere("topik.periode = :periode", { periode: currentPeriode.value }); + + if (search) { + pendaftaranTesisQuery = pendaftaranTesisQuery.andWhere( + new Brackets((qb) => { + qb.where("mahasiswa.nama ILIKE :search", { + search: `%${search}%`, + }).orWhere("mahasiswa.nim ILIKE :search", { search: `%${search}%` }); + }), + ); + } + const pendaftaranTesis = await pendaftaranTesisQuery.getMany(); + + const statusMap = await Promise.all( + pendaftaranTesis.map(async (pendaftaran) => { + return await this.bimbinganService.getBimbinganStatus(pendaftaran); + }), + ); + + return pendaftaranTesis.map((pendaftaran, index) => { + return { + id: pendaftaran.id, + jalurPilihan: pendaftaran.jalurPilihan, + status: statusMap[index], topik: { - periode: currentPeriode.value, + id: pendaftaran.topik.id, + judul: pendaftaran.topik.judul, }, - }, - relations: { - mahasiswa: true, - topik: true, - }, + mahasiswa: { + id: pendaftaran.mahasiswa.id, + nama: pendaftaran.mahasiswa.nama, + nim: pendaftaran.mahasiswa.nim, + }, + }; }); - - return pendaftaranTesis.map((pendaftaran) => ({ - id: pendaftaran.id, - jalurPilihan: pendaftaran.jalurPilihan, - status: "LANCAR", - topik: { - id: pendaftaran.topik.id, - judul: pendaftaran.topik.judul, - }, - mahasiswa: { - id: pendaftaran.mahasiswa.id, - nama: pendaftaran.mahasiswa.nama, - nim: pendaftaran.mahasiswa.nim, - }, - })); } async getStatisticsByJalurPilihan( - penerimaId: string, + dosenId: string, ): Promise<JalurStatisticDto[]> { - const [currentPeriode, penerima] = await Promise.all([ + const [currentPeriode, dosen] = await Promise.all([ this.konfigurasiRepository.findOne({ where: { key: process.env.KONF_PERIODE_KEY }, }), this.penggunaRepository.findOne({ - where: { id: penerimaId }, + where: { id: dosenId }, }), ]); - if (!penerima) { - return []; + if (!dosen) { + throw new BadRequestException("Dosen tidak ditemukan"); } + const statistics = await this.pendaftaranTesisRepository .createQueryBuilder("pendaftaranTesis") .select("pendaftaranTesis.jalurPilihan", "jalurPilihan") .addSelect("COUNT(*)", "count") - .leftJoin("pendaftaranTesis.topik", "topik") - .where("pendaftaranTesis.penerima = :penerima", { penerima: penerima.id }) + .leftJoin("pendaftaranTesis.topik", "topik", "topik.periode = :periode", { + periode: currentPeriode.value, + }) + .innerJoin( + "pendaftaranTesis.dosenBimbingan", + "dosenBimbingan", + "dosenBimbingan.idDosen = :dosenId", + { + dosenId, + }, + ) .andWhere("pendaftaranTesis.status = :status", { status: RegStatus.APPROVED, }) .groupBy("pendaftaranTesis.jalurPilihan") - .addGroupBy("topik.id") - .having("topik.periode = :periode", { - periode: currentPeriode.value, - }) .getRawMany(); + return statistics as JalurStatisticDto[]; } + + async getDashboardMahasiswa( + mahasiswaId: string, + ): Promise<DashboardMahasiswaResDto> { + const currentPeriode = await this.konfigurasiRepository.findOne({ + where: { key: process.env.KONF_PERIODE_KEY }, + }); + + const mahasiswaQuery = this.penggunaRepository + .createQueryBuilder("pengguna") + .select([ + "pengguna.id", + "pengguna.nama", + "pengguna.email", + "pengguna.nim", + ]) + .where("pengguna.id = :id", { id: mahasiswaId }); + const pendaftaranTesisQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pendaftaranTesis") + .select([ + "pendaftaranTesis.id", + "pendaftaranTesis.jalurPilihan", + "pendaftaranTesis.waktuPengiriman", + "pendaftaranTesis.jadwalInterview", + "pendaftaranTesis.waktuKeputusan", + "pendaftaranTesis.status", + "penerima.id", + "penerima.nama", + "penerima.email", + ]) + .leftJoin("pendaftaranTesis.mahasiswa", "mahasiswa") + .leftJoinAndSelect("pendaftaranTesis.topik", "topik") + .leftJoin("pendaftaranTesis.penerima", "penerima") + .where("mahasiswa.id = :id", { id: mahasiswaId }) + .andWhere("topik.periode = :periode", { periode: currentPeriode.value }) + .orderBy("pendaftaranTesis.waktuPengiriman", "DESC"); + + const [mahasiswa, pendaftaranTesis] = await Promise.all([ + mahasiswaQuery.getOne(), + pendaftaranTesisQuery.getOne(), + ]); + + let dosenBimbingan: DosenBimbingan[] = []; + let bimbingan: Bimbingan[] = []; + let seminarSatu: PendaftaranSidsem | null = null; + let seminarDua: PendaftaranSidsem | null = null; + let sidang: PendaftaranSidsem | null = null; + + if (pendaftaranTesis) { + const dosenBimbinganQuery = this.dosenBimbinganRepository + .createQueryBuilder("dosenBimbingan") + .select(["dosen.id", "dosen.nama", "dosen.email"]) + .leftJoin("dosenBimbingan.dosen", "dosen") + .where("dosenBimbingan.idPendaftaran = :id", { + id: pendaftaranTesis.id, + }); + const bimbinganQuery = this.bimbinganRepository + .createQueryBuilder("bimbingan") + .leftJoinAndSelect("bimbingan.berkas", "berkas") + .where("bimbingan.pendaftaranId = :id", { + id: pendaftaranTesis.id, + }); + const [seminarSatuQuery, seminarDuaQuery, sidangQuery] = Object.values( + TipeSidsemEnum, + ).map((tipe) => { + let temp = this.pendaftaranSidsemRepository + .createQueryBuilder("pendaftaranSidsem") + .leftJoinAndSelect("pendaftaranSidsem.ruangan", "ruangan") + .where("pendaftaranSidsem.pendaftaranTesisId = :id", { + id: pendaftaranTesis.id, + }) + .andWhere("pendaftaranSidsem.tipe = :tipe", { + tipe, + }) + .andWhere("NOT pendaftaranSidsem.ditolak"); + + if (tipe !== TipeSidsemEnum.SEMINAR_1) { + temp = temp + .leftJoinAndSelect("pendaftaranSidsem.penguji", "penguji") + .leftJoinAndSelect("penguji.dosen", "dosen"); + } + + return temp; + }); + + [dosenBimbingan, bimbingan, seminarSatu, seminarDua, sidang] = + await Promise.all([ + dosenBimbinganQuery.getMany(), + bimbinganQuery.getMany(), + seminarSatuQuery.getOne(), + seminarDuaQuery.getOne(), + sidangQuery.getOne(), + ]); + } + + return { + mahasiswa, + pendaftaranTesis, + dosenBimbingan: + dosenBimbingan.length > 0 + ? (dosenBimbingan as any as NoNIMUserDashboard[]) + : [pendaftaranTesis.penerima], + bimbingan, + seminarSatu, + seminarDua: { + ...seminarDua, + penguji: seminarDua?.penguji.map((p) => ({ + id: p.dosen.id, + nama: p.dosen.nama, + email: p.dosen.email, + })), + }, + sidang: { + ...sidang, + penguji: sidang?.penguji.map((p) => ({ + id: p.dosen.id, + nama: p.dosen.nama, + email: p.dosen.email, + })), + }, + }; + } } diff --git a/src/dosen-bimbingan/dosen-bimbingan.controller.ts b/src/dosen-bimbingan/dosen-bimbingan.controller.ts index ef0b6acae18dc2d24c5e25dbdf88eaa8158a46df..81a31679c5443f03c6469b478be79566aa2e80b0 100644 --- a/src/dosen-bimbingan/dosen-bimbingan.controller.ts +++ b/src/dosen-bimbingan/dosen-bimbingan.controller.ts @@ -1,30 +1,16 @@ -import { - Body, - Controller, - Delete, - Get, - NotFoundException, - Put, - Query, - UseGuards, -} from "@nestjs/common"; +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 { - DosbimOptQueryDto, - DosbimQueryDto, - GetDosbimResDto, - SuccessResDto, - UpdateDosbimDto, -} from "./dosen-bimbingan.dto"; +import { GetDosbimResDto } from "./dosen-bimbingan.dto"; import { DosenBimbinganService } from "./dosen-bimbingan.service"; @ApiTags("Dosen Bimbingan") @@ -32,49 +18,17 @@ import { DosenBimbinganService } from "./dosen-bimbingan.service"; @ApiBearerAuth() @Controller("dosen-bimbingan") @UseGuards(CustomAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS, RoleEnum.S2_MAHASISWA) 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, - ) + @ApiOperation({ + summary: + "Get all available dosen bimbingan. Roles: ADMIN, S2_TIM_TESIS, S2_MAHASISWA", + }) @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", - }; + async get() { + return await this.dosbimService.getAll(); } } diff --git a/src/dosen-bimbingan/dosen-bimbingan.dto.ts b/src/dosen-bimbingan/dosen-bimbingan.dto.ts index 11d56b1135d145102b6ac69a56474ae66975dac5..23c823e7389a759b7d532c02543e736d5bbaf0d4 100644 --- a/src/dosen-bimbingan/dosen-bimbingan.dto.ts +++ b/src/dosen-bimbingan/dosen-bimbingan.dto.ts @@ -1,12 +1,6 @@ -import { - ArrayMaxSize, - IsArray, - IsUUID, - ArrayMinSize, - ArrayUnique, - IsOptional, -} from "@nestjs/class-validator"; -import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { IsOptional } from "@nestjs/class-validator"; +import { ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { IsUUID } from "class-validator"; import { Pengguna } from "src/entities/pengguna.entity"; export class DosbimOptQueryDto { @@ -16,30 +10,6 @@ export class DosbimOptQueryDto { 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", diff --git a/src/dosen-bimbingan/dosen-bimbingan.service.ts b/src/dosen-bimbingan/dosen-bimbingan.service.ts index 456364ee4bc51946474c2cd5e54e92fc26909fe8..88f0f9d159f8f0e4949c06a4c5bbcb7ad9a9643a 100644 --- a/src/dosen-bimbingan/dosen-bimbingan.service.ts +++ b/src/dosen-bimbingan/dosen-bimbingan.service.ts @@ -1,29 +1,13 @@ -import { - BadRequestException, - Injectable, - InternalServerErrorException, -} from "@nestjs/common"; +import { Injectable } 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"; +import { ArrayContains, 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() { @@ -38,97 +22,4 @@ export class DosenBimbinganService { }, }); } - - 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/entities/berkasBimbingan.entity.ts b/src/entities/berkasBimbingan.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..35b44ace3f7a70db3a1bcbd210c3a95be701c99a --- /dev/null +++ b/src/entities/berkasBimbingan.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { Bimbingan } from "./bimbingan.entity"; +import { IsString, IsUrl } from "@nestjs/class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +@Entity() +export class BerkasBimbingan { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => Bimbingan, (bimbingan) => bimbingan.id, { + orphanedRowAction: "delete", + }) + bimbingan: Bimbingan; + + @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/berkasSubmisiTugas.entity.ts b/src/entities/berkasSubmisiTugas.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..e48d5e8d88f1449b767d3e88f1f9862e8e0f6560 --- /dev/null +++ b/src/entities/berkasSubmisiTugas.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { SubmisiTugas } from "./submisiTugas.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsUUID } from "@nestjs/class-validator"; +import { IsUrl } from "class-validator"; + +@Entity() +export class BerkasSubmisiTugas { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => SubmisiTugas, (submisi) => submisi.id, { + orphanedRowAction: "delete", + }) + submisiTugas: SubmisiTugas; + + @ApiProperty() + @IsString() + @Column({ type: "text" }) + nama: string; + + @ApiProperty({ example: "https://example.com/berkas.pdf" }) + @IsUrl() + @Column({ type: "text" }) + url: string; +} diff --git a/src/entities/berkasSubmisiTugas.ts b/src/entities/berkasSubmisiTugas.ts deleted file mode 100644 index 9694eb7069548036483845915c5a61b8fc7e2a75..0000000000000000000000000000000000000000 --- a/src/entities/berkasSubmisiTugas.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { SubmisiTugas } from "./submisiTugas"; - -@Entity() -export class BerkasSubmisiTugas { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => SubmisiTugas, (submisi) => submisi.id) - submisiTugas: SubmisiTugas; - - @Column({ type: "text" }) - nama: string; - - @Column({ type: "text" }) - url: string; -} diff --git a/src/entities/berkasTugas.entity.ts b/src/entities/berkasTugas.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..c18bccfa9c839754ca8ed7bb0f5789f8841e3ff1 --- /dev/null +++ b/src/entities/berkasTugas.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { Tugas } from "./tugas.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsUrl, IsUUID } from "class-validator"; +import { IsString } from "@nestjs/class-validator"; + +@Entity() +export class BerkasTugas { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => Tugas, (tugas) => tugas.id, { orphanedRowAction: "delete" }) + tugas: Tugas; + + @ApiProperty() + @IsString() + @Column({ type: "text" }) + nama: string; + + @ApiProperty({ example: "https://example.com/berkas.pdf" }) + @IsUrl() + @Column({ type: "text" }) + url: string; +} diff --git a/src/entities/berkasTugas.ts b/src/entities/berkasTugas.ts deleted file mode 100644 index 91cf3ec86a5b6d8f3b243e6d076e41bfa39adcbd..0000000000000000000000000000000000000000 --- a/src/entities/berkasTugas.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Tugas } from "./tugas.entity"; - -@Entity() -export class BerkasTugas { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Tugas, (tugas) => tugas.id) - tugas: Tugas; - - @Column({ type: "text" }) - nama: string; - - @Column({ type: "text" }) - url: string; -} diff --git a/src/entities/bimbingan.entity.ts b/src/entities/bimbingan.entity.ts index 2eeaf674286ff4b2f178dccdf19601088393d59c..a846e824d3d98389e6a2402efad351f49188f9fb 100644 --- a/src/entities/bimbingan.entity.ts +++ b/src/entities/bimbingan.entity.ts @@ -1,6 +1,19 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; import { PendaftaranTesis } from "./pendaftaranTesis.entity"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { BerkasBimbingan } from "./berkasBimbingan.entity"; + +export enum BimbinganStatus { + LANCAR = "LANCAR", + BUTUH_BIMBINGAN = "BUTUH_BIMBINGAN", + TERKENDALA = "TERKENDALA", +} @Entity() export class Bimbingan { @@ -24,10 +37,20 @@ export class Bimbingan { @Column({ type: "date", nullable: true }) bimbinganBerikutnya: string; - @ApiProperty({ type: [String] }) - @Column({ type: "simple-array" }) - berkasLinks: string[]; + @ApiProperty() + @Column({ type: "boolean", default: true }) + disahkan: boolean; @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) pendaftaran: PendaftaranTesis; + + @ApiProperty({ type: [BerkasBimbingan] }) + @OneToMany( + () => BerkasBimbingan, + (berkasBimbingan) => berkasBimbingan.bimbingan, + { + cascade: true, + }, + ) + berkas: BerkasBimbingan[]; } diff --git a/src/entities/kelas.entity.ts b/src/entities/kelas.entity.ts index d1038130a50631f8e71d05d11af6584e77183685..7070b47519d5ed99f52281c454c32e04f01ef2f3 100644 --- a/src/entities/kelas.entity.ts +++ b/src/entities/kelas.entity.ts @@ -6,25 +6,38 @@ import { OneToMany, PrimaryGeneratedColumn, } from "typeorm"; -import { MataKuliah } from "./mataKuliah"; +import { MataKuliah } from "./mataKuliah.entity"; import { ApiProperty } from "@nestjs/swagger"; -import { IsString, Length } from "class-validator"; import { PengajarKelas } from "./pengajarKelas.entity"; -import { MahasiswaKelas } from "./mahasiswaKelas"; +import { MahasiswaKelas } from "./mahasiswaKelas.entity"; +import { + IsPositive, + IsString, + IsUUID, + Length, + MaxLength, +} from "@nestjs/class-validator"; +import { Tugas } from "./tugas.entity"; @Entity() export class Kelas { - @ApiProperty({ example: "d290f1ee-6c54-4b01-90e6-d701748f0851" }) + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() @PrimaryGeneratedColumn("uuid") id: string; @ApiProperty({ example: 1 }) @Column({ type: "smallint" }) + @ApiProperty({ example: 1 }) + @IsPositive() nomor: number; + @ApiProperty() + @IsString() @Column({ type: "text" }) periode: string; + @ApiProperty({ type: MataKuliah }) @ManyToOne(() => MataKuliah, (mataKuliah) => mataKuliah.kode) @JoinColumn({ name: "mataKuliahKode" }) mataKuliah: MataKuliah; @@ -35,6 +48,9 @@ export class Kelas { @Column({ nullable: true }) mataKuliahKode: string; + @ApiProperty({ example: "bg-blue-600/20" }) + @IsString() + @MaxLength(24) @Column({ type: "varchar", length: 24 }) warna: string; @@ -43,4 +59,7 @@ export class Kelas { @OneToMany(() => MahasiswaKelas, (mahasiswa) => mahasiswa.kelas) mahasiswa: MahasiswaKelas[]; + + @OneToMany(() => Tugas, (tugas) => tugas.kelas) + tugas: Tugas[]; } diff --git a/src/entities/mahasiswaKelas.ts b/src/entities/mahasiswaKelas.entity.ts similarity index 96% rename from src/entities/mahasiswaKelas.ts rename to src/entities/mahasiswaKelas.entity.ts index 7d47e0abfa6461f68fc0fa11ef736601cd397c93..0753aa6f75f865913b7e42209248310948dc67c2 100644 --- a/src/entities/mahasiswaKelas.ts +++ b/src/entities/mahasiswaKelas.entity.ts @@ -28,5 +28,5 @@ export class MahasiswaKelas { mahasiswaId: string; @Column({ type: "real", nullable: true }) - nilaiAkhir: number; + nilaiAkhir?: number; } diff --git a/src/entities/mataKuliah.ts b/src/entities/mataKuliah.entity.ts similarity index 70% rename from src/entities/mataKuliah.ts rename to src/entities/mataKuliah.entity.ts index 6f78c692074b3a7c80123deac69c84643c1c7a11..9e1fed5d807a176b89288144623ec72575ef5e33 100644 --- a/src/entities/mataKuliah.ts +++ b/src/entities/mataKuliah.entity.ts @@ -1,6 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsString, Length, MaxLength } from "class-validator"; -import { Column, Entity, PrimaryColumn } from "typeorm"; +import { Column, Entity, OneToMany, PrimaryColumn } from "typeorm"; +import { Kelas } from "./kelas.entity"; @Entity() export class MataKuliah { @@ -15,4 +16,7 @@ export class MataKuliah { @MaxLength(256) @Column({ type: "varchar", length: 256 }) nama: string; + + @OneToMany(() => Kelas, (kelas) => kelas.mataKuliah) + kelas: Kelas[]; } diff --git a/src/entities/pembimbingSeminar.entity.ts b/src/entities/pembimbingSeminar.entity.ts deleted file mode 100644 index 024ebe5b57e6835441faa33d687137fb82565cbb..0000000000000000000000000000000000000000 --- a/src/entities/pembimbingSeminar.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Seminar } from "./seminar.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class PembimbingSeminar { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Seminar, (seminar) => seminar.id) - seminar: Seminar; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - dosen: Pengguna; -} diff --git a/src/entities/pendaftaranSidsem.ts b/src/entities/pendaftaranSidsem.ts new file mode 100644 index 0000000000000000000000000000000000000000..a733c9cd7aedf8825202a92c3b509e271486847f --- /dev/null +++ b/src/entities/pendaftaranSidsem.ts @@ -0,0 +1,62 @@ +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; +import { PendaftaranTesis } from "./pendaftaranTesis.entity"; +// import { Ruangan } from "./ruangan.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { PengujiSidsem } from "./pengujiSidsem.entity"; + +export enum TipeSidsemEnum { + SEMINAR_1 = "SEMINAR_1", + SEMINAR_2 = "SEMINAR_2", + SIDANG = "SIDANG", +} + +@Entity() +export class PendaftaranSidsem { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @PrimaryGeneratedColumn("uuid") + id: string; + + @ApiProperty({ enum: TipeSidsemEnum }) + @Column({ type: "enum", enum: TipeSidsemEnum }) + tipe: TipeSidsemEnum; + + @ApiProperty() + @Column({ type: "boolean", default: false }) + ditolak: boolean; + + @ApiProperty() + @Column({ type: "boolean", nullable: true }) + lulus: boolean; + + @ApiProperty() + @Column({ type: "timestamptz", nullable: true }) + waktuMulai: Date; + + @ApiProperty() + @Column({ type: "timestamptz", nullable: true }) + waktuSelesai: Date; + + @ApiProperty() + @Column({ type: "text", nullable: true }) + linkw2m: string; + + @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) + pendaftaranTesis: PendaftaranTesis; + + // @ApiProperty({ type: Ruangan, nullable: true }) + // @ManyToOne(() => Ruangan, (ruangan) => ruangan.id) + // ruangan: Ruangan; + + @ApiProperty() + @Column({ type: "text", nullable: true }) + ruangan: string; + + @OneToMany(() => PengujiSidsem, (pengujiSidsem) => pengujiSidsem.sidsem) + penguji: PengujiSidsem[]; +} diff --git a/src/entities/pendaftaranTesis.entity.ts b/src/entities/pendaftaranTesis.entity.ts index 8750a05bea0955336a4d92425dc601874d4e29c8..637a05e71e7be84615568bcaef45f0adc2d14e2c 100644 --- a/src/entities/pendaftaranTesis.entity.ts +++ b/src/entities/pendaftaranTesis.entity.ts @@ -1,7 +1,14 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; import { Pengguna } from "./pengguna.entity"; import { Topik } from "./topik.entity"; import { ApiProperty } from "@nestjs/swagger"; +import { DosenBimbingan } from "./dosenBimbingan.entity"; export enum RegStatus { NOT_ASSIGNED = "NOT_ASSIGNED", @@ -48,12 +55,21 @@ export class PendaftaranTesis { @Column({ type: "enum", enum: RegStatus, default: RegStatus.NOT_ASSIGNED }) status: RegStatus; + @ApiProperty({ type: Topik }) @ManyToOne(() => Topik, (topik) => topik.id) topik: Topik; + @ApiProperty() @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) mahasiswa: Pengguna; + @ApiProperty() @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) penerima: Pengguna; + + @OneToMany( + () => DosenBimbingan, + (dosenBimbingan) => dosenBimbingan.pendaftaran, + ) + dosenBimbingan: DosenBimbingan[]; } diff --git a/src/entities/pengguna.entity.ts b/src/entities/pengguna.entity.ts index db1cf81be9dad9a126077af2c9cb7cf2ded1a7e1..7e4899c2764d11b3871fa2d7e502aed033a04406 100644 --- a/src/entities/pengguna.entity.ts +++ b/src/entities/pengguna.entity.ts @@ -1,11 +1,14 @@ +import { IsString } from "@nestjs/class-validator"; import { ApiHideProperty, ApiProperty, ApiPropertyOptional, } from "@nestjs/swagger"; import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; -import { MahasiswaKelas } from "./mahasiswaKelas"; +import { MahasiswaKelas } from "./mahasiswaKelas.entity"; import { PengajarKelas } from "./pengajarKelas.entity"; +import { PendaftaranTesis } from "./pendaftaranTesis.entity"; +import { SubmisiTugas } from "./submisiTugas.entity"; export enum RoleEnum { ADMIN = "ADMIN", @@ -58,4 +61,14 @@ export class Pengguna { @OneToMany(() => PengajarKelas, (pengajarKelas) => pengajarKelas.pengajar) pengajarKelas: PengajarKelas[]; + @ApiPropertyOptional() + @IsString() + @Column({ type: "text", nullable: true }) + kontak: string; + + @OneToMany(() => PendaftaranTesis, (pendaftaran) => pendaftaran.mahasiswa) + pendaftaranTesis: PendaftaranTesis[]; + + @OneToMany(() => SubmisiTugas, (submisiTugas) => submisiTugas.mahasiswa) + submisiTugas: SubmisiTugas[]; } diff --git a/src/entities/pengujiSidang.entity.ts b/src/entities/pengujiSidang.entity.ts deleted file mode 100644 index dfcd36b5c2f5e06bebad302b3488aedff86338c7..0000000000000000000000000000000000000000 --- a/src/entities/pengujiSidang.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Sidang } from "./sidang.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class PengujiSidang { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Sidang, (sidang) => sidang.id) - sidang: Sidang; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - dosen: Pengguna; -} diff --git a/src/entities/pembimbingSidang.entity.ts b/src/entities/pengujiSidsem.entity.ts similarity index 54% rename from src/entities/pembimbingSidang.entity.ts rename to src/entities/pengujiSidsem.entity.ts index a3d9dbec55b4d3e94d7a1e6d8b14262b10752e3d..09aebe7c16acbb068ae8a26c341b02e996e6a7a3 100644 --- a/src/entities/pembimbingSidang.entity.ts +++ b/src/entities/pengujiSidsem.entity.ts @@ -1,14 +1,17 @@ import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Sidang } from "./sidang.entity"; import { Pengguna } from "./pengguna.entity"; +import { PendaftaranSidsem } from "./pendaftaranSidsem"; @Entity() -export class PembimbingSidang { +export class PengujiSidsem { @PrimaryGeneratedColumn("uuid") id: string; - @ManyToOne(() => Sidang, (sidang) => sidang.id) - sidang: Sidang; + @ManyToOne( + () => PendaftaranSidsem, + (pendaftaranSidsem) => pendaftaranSidsem.id, + ) + sidsem: PendaftaranSidsem; @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) dosen: Pengguna; diff --git a/src/entities/rangeJadwalSeminar.entity.ts b/src/entities/rangeJadwalSeminar.entity.ts deleted file mode 100644 index fa16bf749d40cdfeff7253f61bc098063a7348a3..0000000000000000000000000000000000000000 --- a/src/entities/rangeJadwalSeminar.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class RangeJadwalSeminar { - @PrimaryGeneratedColumn("uuid") - id: string; -} diff --git a/src/entities/rangeJadwalSidang.entity.ts b/src/entities/rangeJadwalSidang.entity.ts deleted file mode 100644 index 6479aef875d9d7b23cd9db1c00317af5e60756cd..0000000000000000000000000000000000000000 --- a/src/entities/rangeJadwalSidang.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class RangeJadwalSidang { - @PrimaryGeneratedColumn("uuid") - id: string; -} diff --git a/src/entities/ruangan.entity.ts b/src/entities/ruangan.entity.ts index 97df4c092f30bd329d8c3ece86d385bd0e6300c2..86dfaf804683751d50f397df3a55f00488dc0bfa 100644 --- a/src/entities/ruangan.entity.ts +++ b/src/entities/ruangan.entity.ts @@ -1,7 +1,13 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; +// import { ApiProperty } from "@nestjs/swagger"; +// import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -@Entity() -export class Ruangan { - @PrimaryGeneratedColumn("uuid") - id: string; -} +// @Entity() +// export class Ruangan { +// @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) +// @PrimaryGeneratedColumn("uuid") +// id: string; + +// @ApiProperty() +// @Column({ type: "text" }) +// nama: string; +// } diff --git a/src/entities/seminar.entity.ts b/src/entities/seminar.entity.ts deleted file mode 100644 index 7b0e30b783be1e1401448e73f5fd078f7d4cdd0c..0000000000000000000000000000000000000000 --- a/src/entities/seminar.entity.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Pengguna } from "./pengguna.entity"; -import { RangeJadwalSeminar } from "./rangeJadwalSeminar.entity"; -import { Ruangan } from "./ruangan.entity"; - -@Entity() -export class Seminar { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; - - @ManyToOne( - () => RangeJadwalSeminar, - (rangeJadwalSeminar) => rangeJadwalSeminar.id, - ) - rangeJadwal: RangeJadwalSeminar; - - @ManyToOne(() => Ruangan, (ruangan) => ruangan.id) - ruangan: Ruangan; -} diff --git a/src/entities/sidang.entity.ts b/src/entities/sidang.entity.ts deleted file mode 100644 index c2dc57ecdca72af17a7496883e74a716f46dbf1c..0000000000000000000000000000000000000000 --- a/src/entities/sidang.entity.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Pengguna } from "./pengguna.entity"; -import { RangeJadwalSidang } from "./rangeJadwalSidang.entity"; -import { Ruangan } from "./ruangan.entity"; - -@Entity() -export class Sidang { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; - - @ManyToOne( - () => RangeJadwalSidang, - (rangeJadwalSidang) => rangeJadwalSidang.id, - ) - rangeJadwal: RangeJadwalSidang; - - @ManyToOne(() => Ruangan, (ruangan) => ruangan.id) - ruangan: Ruangan; -} diff --git a/src/entities/submisiTugas.ts b/src/entities/submisiTugas.entity.ts similarity index 50% rename from src/entities/submisiTugas.ts rename to src/entities/submisiTugas.entity.ts index ef0c8da1a854dc6b985b23b313a65eaba2aa7d22..be380d37d6c0b4dadf641f794735a343990e13b5 100644 --- a/src/entities/submisiTugas.ts +++ b/src/entities/submisiTugas.entity.ts @@ -1,34 +1,53 @@ import { Column, Entity, + JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn, } from "typeorm"; import { Pengguna } from "./pengguna.entity"; import { Tugas } from "./tugas.entity"; -import { BerkasSubmisiTugas } from "./berkasSubmisiTugas"; +import { BerkasSubmisiTugas } from "./berkasSubmisiTugas.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsUUID } from "class-validator"; +import { IsBoolean } from "@nestjs/class-validator"; @Entity() export class SubmisiTugas { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() @PrimaryGeneratedColumn("uuid") id: string; @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "mahasiswaId" }) mahasiswa: Pengguna; + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @Column() + mahasiswaId: string; + + @ApiProperty() + @IsString() @Column({ type: "text" }) jawaban: string; + @ApiProperty({ description: "true means submitted, false means draft" }) + @IsBoolean() @Column({ type: "boolean" }) isSubmitted: boolean; // false means draft (saved), true means submitted + @ApiProperty({ type: [BerkasSubmisiTugas] }) @OneToMany( () => BerkasSubmisiTugas, (berkasSubmisiTugas) => berkasSubmisiTugas.submisiTugas, + { cascade: true }, ) berkasSubmisiTugas: BerkasSubmisiTugas[]; + @ApiProperty() @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP", @@ -37,5 +56,11 @@ export class SubmisiTugas { submittedAt: Date; @ManyToOne(() => Tugas, (tugas) => tugas.id) + @JoinColumn({ name: "tugasId" }) tugas: Tugas; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @Column() + tugasId: string; } diff --git a/src/entities/tugas.entity.ts b/src/entities/tugas.entity.ts index 563bcb32a0d12b55e6e3a30232bea75a162a233d..2424b87973fd01fe854e40ca50079dbc40e0f4ee 100644 --- a/src/entities/tugas.entity.ts +++ b/src/entities/tugas.entity.ts @@ -1,46 +1,88 @@ import { Column, Entity, + JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn, } from "typeorm"; import { Kelas } from "./kelas.entity"; import { Pengguna } from "./pengguna.entity"; -import { BerkasTugas } from "./berkasTugas"; +import { BerkasTugas } from "./berkasTugas.entity"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsUUID, MaxLength } from "class-validator"; +import { IsString } from "@nestjs/class-validator"; +import { SubmisiTugas } from "./submisiTugas.entity"; @Entity() export class Tugas { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() @PrimaryGeneratedColumn("uuid") id: string; @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "pembuatId" }) pembuat: Pengguna; + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @Column() + pembuatId: string; + @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "pengubahId" }) pengubah: Pengguna; + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @Column() + pengubahId: string; + + @ApiProperty() + @IsString() + @MaxLength(256) @Column({ type: "varchar", length: 256 }) judul: string; + @ApiProperty() + @IsDateString() @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) waktuMulai: Date; + @ApiProperty() + @IsDateString() @Column({ type: "timestamptz" }) waktuSelesai: Date; + @ApiProperty() + @IsString() @Column({ type: "text" }) deskripsi: string; - @OneToMany(() => BerkasTugas, (berkasTugas) => berkasTugas.tugas) + @ApiProperty({ type: [BerkasTugas] }) + @OneToMany(() => BerkasTugas, (berkasTugas) => berkasTugas.tugas, { + cascade: true, + }) berkasTugas: BerkasTugas[]; + @ApiProperty() @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) createdAt: Date; + @ApiProperty() @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) updatedAt: Date; @ManyToOne(() => Kelas, (kelas) => kelas.id) + @JoinColumn({ name: "kelasId" }) kelas: Kelas; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @Column() + kelasId: string; + + @OneToMany(() => SubmisiTugas, (submisiTugas) => submisiTugas.tugas) + submisiTugas: SubmisiTugas[]; } diff --git a/src/kelas/kelas.constant.ts b/src/kelas/kelas.constant.ts new file mode 100644 index 0000000000000000000000000000000000000000..050cbbca5914f9eb24c9e646e62e2629d047a51b --- /dev/null +++ b/src/kelas/kelas.constant.ts @@ -0,0 +1,20 @@ +export const CARD_COLORS = [ + "bg-blue-600/20", + "bg-yellow-600/20", + "bg-green-600/20", + "bg-red-600/20", + "bg-purple-600/20", + "bg-pink-600/20", + "bg-indigo-600/20", + "bg-cyan-600/20", + "bg-amber-600/20", + "bg-lime-600/20", + "bg-emerald-600/20", + "bg-teal-600/20", + "bg-cyan-600/20", + "bg-violet-600/20", + "bg-fuchsia-600/20", + "bg-rose-600/20", + "bg-sky-600/20", + "bg-cyan-600/20", +]; diff --git a/src/kelas/kelas.controller.ts b/src/kelas/kelas.controller.ts index 7469c69ce7f7dcc1a1abb67838ea8f68de67dfc8..1ba7c70bdae05a77341f67b1ce6a31d61248219a 100644 --- a/src/kelas/kelas.controller.ts +++ b/src/kelas/kelas.controller.ts @@ -4,37 +4,51 @@ import { Delete, ForbiddenException, Get, + Param, Post, + Put, Query, Req, UseGuards, } from "@nestjs/common"; import { AssignKelasDto, + ByIdKelasDto, CreateKelasDto, + DeleteKelasDto, + GetKelasDetailRespDto, GetKelasQueryDto, - GetListKelasRespDto, + GetKelasRespDto, + GetNextNomorResDto, + IdKelasResDto, KodeRespDto, MessageResDto, UnassignKelasDto, UserKelasResDto, + UpdateKelasDto, + SearchQueryDto, + UpdateKelasPenggunaDto, } from "./kelas.dto"; import { Request } from "express"; import { AuthDto } from "src/auth/auth.dto"; import { RoleEnum } from "src/entities/pengguna.entity"; import { + ApiBadRequestResponse, ApiBearerAuth, ApiCookieAuth, ApiCreatedResponse, ApiInternalServerErrorResponse, + ApiNotFoundResponse, ApiOkResponse, + ApiOperation, ApiTags, } from "@nestjs/swagger"; import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; import { RolesGuard } from "src/middlewares/roles.guard"; import { KelasService } from "./kelas.service"; import { Roles } from "src/middlewares/roles.decorator"; -import { MataKuliah } from "src/entities/mataKuliah"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { Kelas } from "src/entities/kelas.entity"; @ApiTags("Kelas") @ApiBearerAuth() @@ -44,8 +58,17 @@ import { MataKuliah } from "src/entities/mataKuliah"; export class KelasController { constructor(private readonly kelasServ: KelasService) {} - @ApiOkResponse({ type: GetListKelasRespDto, isArray: true }) - @Roles(RoleEnum.S2_KULIAH, RoleEnum.S2_MAHASISWA, RoleEnum.S2_TIM_TESIS) + @ApiOperation({ + summary: + "Get list of kelas. Roles: S2_KULIAH, S2_MAHASISWA, S2_TIM_TESIS, ADMIN", + }) + @ApiOkResponse({ type: GetKelasRespDto, isArray: true }) + @Roles( + RoleEnum.S2_KULIAH, + RoleEnum.S2_MAHASISWA, + RoleEnum.S2_TIM_TESIS, + RoleEnum.ADMIN, + ) @Get() async getListKelas(@Query() query: GetKelasQueryDto, @Req() req: Request) { let idMahasiswa = undefined; @@ -65,17 +88,66 @@ export class KelasController { idMahasiswa = id; } - return await this.kelasServ.getListKelas(idMahasiswa, idPengajar); + return await this.kelasServ.getListKelas( + idMahasiswa, + idPengajar, + query.kodeMatkul, + query.search, + ); } - @Roles(RoleEnum.S2_TIM_TESIS) + @ApiOkResponse({ type: MataKuliah, isArray: true }) + @Get("/mata-kuliah") + async getAllMatkul() { + return await this.kelasServ.getAllMatkul(); + } + + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOkResponse({ type: GetNextNomorResDto }) + @Get("/next-nomor/:kodeMatkul") + async getNextNomor( + @Param("kodeMatkul") kodeMatkul: string, + ): Promise<GetNextNomorResDto> { + const nomor = await this.kelasServ.getNextNomorKelas(kodeMatkul); + + return { + nomor, + }; + } + + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOkResponse({ type: IdKelasResDto }) + @ApiBadRequestResponse({ + description: "Nomor kelas sudah ada", + }) + @ApiInternalServerErrorResponse({ + description: "Gagal membuat kelas", + }) @Post() - async createKelas(@Body() body: CreateKelasDto) { + async createKelas(@Body() body: CreateKelasDto): Promise<IdKelasResDto> { return await this.kelasServ.create(body); } + // TODO: restrict payload + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOkResponse({ type: IdKelasResDto }) + @ApiNotFoundResponse({ + description: "Kelas dengan id (dan nomor) yang terkait tidak ditemukan", + }) + @ApiBadRequestResponse({ + description: + "(Saat pembuatan kelas) nomor kelas sudah ada atau mataKuliahKode tidak ada", + }) + @ApiInternalServerErrorResponse({ + description: "Gagal memperbarui kelas atau gagal membuat kelas", + }) + @Put() + async updateKelas(@Body() body: UpdateKelasDto): Promise<IdKelasResDto> { + return await this.kelasServ.updateOrCreate(body); + } + @ApiCreatedResponse({ type: KodeRespDto }) - @Roles(RoleEnum.S2_TIM_TESIS) + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) @Post("mata-kuliah") async createMataKuliah(@Body() body: MataKuliah) { return await this.kelasServ.createMatkul(body); @@ -84,8 +156,10 @@ export class KelasController { @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) @ApiOkResponse({ type: UserKelasResDto, isArray: true }) @Get("/mahasiswa") - async getMahasiswa(): Promise<UserKelasResDto[]> { - return await this.kelasServ.getKelasPengguna("MAHASISWA"); + async getMahasiswa( + @Query() query: SearchQueryDto, + ): Promise<UserKelasResDto[]> { + return await this.kelasServ.getKelasPengguna("MAHASISWA", query.search); } @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) @@ -111,8 +185,8 @@ export class KelasController { @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) @ApiOkResponse({ type: UserKelasResDto, isArray: true }) @Get("/dosen") - async getDosen(): Promise<UserKelasResDto[]> { - return await this.kelasServ.getKelasPengguna("DOSEN"); + async getDosen(@Query() query: SearchQueryDto): Promise<UserKelasResDto[]> { + return await this.kelasServ.getKelasPengguna("DOSEN", query.search); } @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) @@ -132,4 +206,109 @@ export class KelasController { ): Promise<MessageResDto> { return await this.kelasServ.unassignKelasDosen(body); } + + @ApiOkResponse({ type: Kelas }) + @ApiNotFoundResponse({ description: "Kelas tidak ditemukan" }) + @ApiInternalServerErrorResponse({ description: "Gagal menghapus kelas" }) + @Delete() + async delete(@Body() body: DeleteKelasDto): Promise<Kelas> { + return await this.kelasServ.delete(body); + } + + @Roles( + RoleEnum.S2_TIM_TESIS, + RoleEnum.ADMIN, + RoleEnum.S2_KULIAH, + RoleEnum.S2_MAHASISWA, + ) + @ApiOkResponse({ type: GetKelasRespDto }) + @ApiOperation({ + summary: + "Get kelas general information by kelas id. Roles: S2_KULIAH, S2_MAHASISWA, S2_TIM_TESIS, ADMIN", + }) + @Get("/:id") + async getById( + @Param() param: ByIdKelasDto, + @Req() req: Request, + ): Promise<GetKelasRespDto> { + let idMahasiswa = undefined; + let idPengajar = undefined; + + const { id, roles } = req.user as AuthDto; + + if ( + !roles.includes(RoleEnum.S2_TIM_TESIS) && + !roles.includes(RoleEnum.ADMIN) + ) { + if (roles.includes(RoleEnum.S2_KULIAH)) { + idPengajar = id; + } else { + // requester only has S2_MAHASISWA access + idMahasiswa = id; + } + } + + return await this.kelasServ.getById(param.id, idMahasiswa, idPengajar); + } + + @Roles( + RoleEnum.S2_TIM_TESIS, + RoleEnum.ADMIN, + RoleEnum.S2_KULIAH, + RoleEnum.S2_MAHASISWA, + ) + @ApiOperation({ + summary: + "Get kelas mahasiswa and pengajar list by kelas id. Roles: S2_KULIAH, S2_MAHASISWA, S2_TIM_TESIS, ADMIN", + }) + @ApiOkResponse({ type: GetKelasDetailRespDto }) + @Get("/:id/detail") + async getKelasDetail(@Param() param: ByIdKelasDto, @Req() req: Request) { + let idMahasiswa = undefined; + let idPengajar = undefined; + + const { id, roles } = req.user as AuthDto; + + if ( + !roles.includes(RoleEnum.S2_TIM_TESIS) && + !roles.includes(RoleEnum.ADMIN) + ) { + if (roles.includes(RoleEnum.S2_KULIAH)) { + idPengajar = id; + } else { + // requester only has S2_MAHASISWA access + idMahasiswa = id; + } + } + + return await this.kelasServ.getKelasDetail( + param.id, + idMahasiswa, + idPengajar, + ); + } + + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOperation({ + summary: "Update kelas mahasiswa. Roles: S2_TIM_TESIS, ADMIN", + }) + @ApiOkResponse({ type: ByIdKelasDto }) + @Put("/mahasiswa") + async updateKelasMahasiswa( + @Body() body: UpdateKelasPenggunaDto, + ): Promise<ByIdKelasDto> { + return await this.kelasServ.updateKelas(body, "MAHASISWA"); + } + + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOperation({ + summary: "Update kelas dosen. Roles: S2_TIM_TESIS, ADMIN", + }) + @ApiOkResponse({ type: ByIdKelasDto }) + @Put("/dosen") + async updateKelasDosen( + @Body() body: UpdateKelasPenggunaDto, + ): Promise<ByIdKelasDto> { + return await this.kelasServ.updateKelas(body, "DOSEN"); + } } diff --git a/src/kelas/kelas.dto.ts b/src/kelas/kelas.dto.ts index 32f9add0a49e331b1e8917d1cab5e1041bea5c8c..3647164851b0a08f38fda3c651db4cc17587c086 100644 --- a/src/kelas/kelas.dto.ts +++ b/src/kelas/kelas.dto.ts @@ -1,12 +1,35 @@ -import { IsUUID } from "@nestjs/class-validator"; -import { ApiProperty, PickType } from "@nestjs/swagger"; -import { IsEnum } from "class-validator"; +import { + IsEnum, + IsOptional, + IsPositive, + IsUUID, +} from "@nestjs/class-validator"; +import { + ApiProperty, + PickType, + PartialType, + ApiPropertyOptional, +} from "@nestjs/swagger"; import { Kelas } from "src/entities/kelas.entity"; -import { MataKuliah } from "src/entities/mataKuliah"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; export class CreateKelasDto extends PickType(Kelas, [ "mataKuliahKode", +] as const) { + @ApiPropertyOptional({ example: 1 }) + @IsOptional() + @IsPositive() + nomor: number; +} + +export class UpdateKelasDto extends PartialType(Kelas) {} + +export class IdKelasResDto extends PickType(Kelas, ["id"] as const) {} + +export class DeleteKelasDto extends PickType(Kelas, [ + "mataKuliahKode", + "nomor", ] as const) {} export class GetKelasQueryDto { @@ -15,20 +38,42 @@ export class GetKelasQueryDto { enum: [RoleEnum.S2_KULIAH, RoleEnum.S2_MAHASISWA, RoleEnum.S2_TIM_TESIS], }) view: RoleEnum.S2_KULIAH | RoleEnum.S2_MAHASISWA | RoleEnum.S2_TIM_TESIS; + + @ApiPropertyOptional({ example: "IF3270" }) + @IsOptional() + kodeMatkul: string; + + @ApiPropertyOptional({ example: "Intelegensi Buatan" }) + @IsOptional() + search: string; +} + +export class SearchQueryDto { + @ApiPropertyOptional({ example: "Intelegensi Buatan" }) + @IsOptional() + search: string; } -export class GetListKelasRespDto { +export class ByIdKelasDto extends PickType(Kelas, ["id"] as const) {} + +export class GetKelasRespDto { @ApiProperty() id: string; @ApiProperty({ example: "K02" }) nomor: string; - @ApiProperty({ example: "IF4031 Pengembangan Aplikasi Terdistribusi" }) - mata_kuliah: string; + @ApiProperty({ example: "IF3270" }) + kode_mata_kuliah: string; + + @ApiProperty({ example: "Pengembangan Aplikasi Terdistribusi" }) + nama_mata_kuliah: string; @ApiProperty() jumlah_mahasiswa: number; + + @ApiProperty({ example: "bg-blue-600/20" }) + warna: string; } export class KodeRespDto extends PickType(MataKuliah, ["kode"] as const) {} @@ -56,7 +101,10 @@ class KelasUser extends PickType(Kelas, [ "id", "nomor", "mataKuliahKode", -] as const) {} +] as const) { + @ApiProperty() + mataKuliahNama: string; +} export class UserKelasResDto extends PickType(Pengguna, [ "id", @@ -66,3 +114,34 @@ export class UserKelasResDto extends PickType(Pengguna, [ @ApiProperty({ type: [KelasUser] }) kelas: KelasUser[]; } +export class GetNextNomorResDto { + @ApiProperty({ example: 2 }) + nomor: number; +} + +class PickedPengajarKelasDto extends PickType(Pengguna, [ + "id", + "nama", +] as const) {} + +class PickedMahasiswaKelasDto extends PickType(Pengguna, [ + "id", + "nama", + "nim", +] as const) {} + +export class GetKelasDetailRespDto extends PickType(Kelas, ["id"] as const) { + @ApiProperty({ type: [PickedPengajarKelasDto] }) + pengajar: PickedPengajarKelasDto[]; + + @ApiProperty({ type: [PickedMahasiswaKelasDto] }) + mahasiswa: PickedMahasiswaKelasDto[]; +} + +export class UpdateKelasPenggunaDto extends PickType(AssignKelasDto, [ + "kelasIds" as const, +]) { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + penggunaId: string; +} diff --git a/src/kelas/kelas.module.ts b/src/kelas/kelas.module.ts index c0411307dd06faf40d710ac28e378af4eccdba24..dde13c556b1ffb854621c5394945ace1ed81225c 100644 --- a/src/kelas/kelas.module.ts +++ b/src/kelas/kelas.module.ts @@ -6,9 +6,9 @@ import { AuthModule } from "src/auth/auth.module"; import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; import { KelasController } from "./kelas.controller"; import { CustomStrategy } from "src/middlewares/custom.strategy"; -import { MataKuliah } from "src/entities/mataKuliah"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; import { Pengguna } from "src/entities/pengguna.entity"; -import { MahasiswaKelas } from "src/entities/mahasiswaKelas"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; import { PengajarKelas } from "src/entities/pengajarKelas.entity"; @Module({ diff --git a/src/kelas/kelas.service.ts b/src/kelas/kelas.service.ts index 908d52e1e442b6f396a9986464efa9ad616920a0..f55b832ce066eb60d33e6e2ce1900ddbd9de5cbe 100644 --- a/src/kelas/kelas.service.ts +++ b/src/kelas/kelas.service.ts @@ -2,21 +2,29 @@ import { BadRequestException, Injectable, InternalServerErrorException, + NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Kelas } from "src/entities/kelas.entity"; import { Brackets, DataSource, Repository } from "typeorm"; import { - AssignKelasDto, CreateKelasDto, - GetListKelasRespDto, + AssignKelasDto, + DeleteKelasDto, + GetKelasDetailRespDto, + GetKelasRespDto, + IdKelasResDto, + UpdateKelasDto, MessageResDto, UnassignKelasDto, + UpdateKelasPenggunaDto, + ByIdKelasDto, } from "./kelas.dto"; import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; -import { MataKuliah } from "src/entities/mataKuliah"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { CARD_COLORS } from "./kelas.constant"; import { Pengguna } from "src/entities/pengguna.entity"; -import { MahasiswaKelas } from "src/entities/mahasiswaKelas"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; import { PengajarKelas } from "src/entities/pengajarKelas.entity"; @Injectable() @@ -35,15 +43,13 @@ export class KelasService { private pengajarKelasRepo: Repository<PengajarKelas>, private datasource: DataSource, ) {} - - async getListKelas(idMahasiswa?: string, idPengajar?: string) { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); - - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); - } + async getListKelas( + idMahasiswa?: string, + idPengajar?: string, + kodeMatkul?: string, + search?: string, + ) { + const currPeriod = await this.konfService.getPeriodeOrFail(); let baseQuery = this.kelasRepo .createQueryBuilder("kelas") @@ -52,6 +58,7 @@ export class KelasService { .select([ "kelas.id AS id", "kelas.nomor AS nomor", + "kelas.warna AS warna", "mataKuliah.kode AS kode_mata_kuliah", "mataKuliah.nama AS nama_mata_kuliah", "COUNT(mahasiswa) AS jumlah_mahasiswa", @@ -76,47 +83,201 @@ export class KelasService { }); } + if (kodeMatkul) { + baseQuery = baseQuery.andWhere("mataKuliah.kode = :kodeMatkul", { + kodeMatkul, + }); + } + + if (search) { + baseQuery = baseQuery.andWhere( + new Brackets((qb) => { + qb.where("mataKuliah.kode ILIKE :search", { + search: `%${search}%`, + }).orWhere("mataKuliah.nama ILIKE :search", { + search: `%${search}%`, + }); + }), + ); + } + const result = await baseQuery .groupBy("kelas.id, mataKuliah.kode") .getRawMany(); - const mapped: GetListKelasRespDto[] = result.map((r) => ({ + const mapped: GetKelasRespDto[] = result.map((r) => ({ id: r.id, nomor: "K" + `${r.nomor}`.padStart(2, "0"), - mata_kuliah: `${r.kode_mata_kuliah} ${r.nama_mata_kuliah}`, + kode_mata_kuliah: r.kode_mata_kuliah, + nama_mata_kuliah: r.nama_mata_kuliah, jumlah_mahasiswa: parseInt(r.jumlah_mahasiswa), + warna: r.warna, })); return mapped; } - async create(createDto: CreateKelasDto) { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); + async getById(id: string, idMahasiswa?: string, idPengajar?: string) { + const currPeriod = await this.konfService.getPeriodeOrFail(); - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); + let baseQuery = this.kelasRepo + .createQueryBuilder("kelas") + .leftJoinAndSelect("kelas.mahasiswa", "mahasiswa") + .leftJoinAndSelect("kelas.mataKuliah", "mataKuliah") + .select([ + "kelas.id AS id", + "kelas.nomor AS nomor", + "kelas.warna AS warna", + "mataKuliah.kode AS kode_mata_kuliah", + "mataKuliah.nama AS nama_mata_kuliah", + "COUNT(mahasiswa) AS jumlah_mahasiswa", + ]) + .where("kelas.id = :id", { id }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + if (idMahasiswa) { + baseQuery = baseQuery + .innerJoin("kelas.mahasiswa", "mahasiswa_filter") + .andWhere("mahasiswa_filter.mahasiswaId = :idMahasiswa", { + idMahasiswa, + }); } - const maxClass = await this.kelasRepo.findOne({ - where: { - mataKuliahKode: createDto.mataKuliahKode, - periode: currPeriod, - }, - order: { - nomor: "DESC", - }, - }); + if (idPengajar) { + baseQuery = baseQuery + .innerJoin("kelas.pengajar", "pengajar") + .andWhere("pengajar.pengajarId = :idPengajar", { + idPengajar, + }); + } + + const result = await baseQuery + .groupBy("kelas.id, mataKuliah.kode") + .getRawOne(); - const num = maxClass ? maxClass.nomor + 1 : 1; + if (!result) { + throw new NotFoundException("Kelas tidak ditemukan"); + } + + const mapped: GetKelasRespDto = { + id: result.id, + nomor: "K" + `${result.nomor}`.padStart(2, "0"), + kode_mata_kuliah: result.kode_mata_kuliah, + nama_mata_kuliah: result.nama_mata_kuliah, + jumlah_mahasiswa: parseInt(result.jumlah_mahasiswa), + warna: result.warna, + }; - return await this.kelasRepo.insert({ + return mapped; + } + + async getKelasDetail( + idKelas: string, + idMahasiswa?: string, + idPengajar?: string, + ) { + const currPeriod = await this.konfService.getPeriodeOrFail(); + + let baseQuery = this.kelasRepo + .createQueryBuilder("kelas") + .leftJoinAndSelect("kelas.mahasiswa", "mahasiswaKelas") + .leftJoinAndSelect("mahasiswaKelas.mahasiswa", "mahasiswa") + .leftJoinAndSelect("kelas.pengajar", "pengajarKelas") + .leftJoinAndSelect("pengajarKelas.pengajar", "pengajar") + .select([ + "kelas.id", + "mahasiswaKelas.id", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "pengajarKelas.id", + "pengajar.id", + "pengajar.nama", + ]) + .orderBy("pengajar.nama", "ASC") + .addOrderBy("mahasiswa.nim", "ASC") + .where("kelas.id = :idKelas", { idKelas }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + if (idMahasiswa) { + baseQuery = baseQuery + .innerJoin("kelas.mahasiswa", "mahasiswaFilter") + .andWhere("mahasiswaFilter.mahasiswaId = :idMahasiswa", { + idMahasiswa, + }); + } + + if (idPengajar) { + baseQuery = baseQuery + .innerJoin("kelas.pengajar", "pengajarFilter") + .andWhere("pengajarFilter.pengajarId = :idPengajar", { + idPengajar, + }); + } + + const result = await baseQuery.getOne(); + + if (!result) { + throw new NotFoundException( + "Kelas tidak ditemukan di antara kelas yang dapat Anda akses", + ); + } + + const mapped: GetKelasDetailRespDto = { + id: result.id, + pengajar: result.pengajar.map((p) => ({ + id: p.pengajar.id, + nama: p.pengajar.nama, + })), + mahasiswa: result.mahasiswa.map((m) => ({ + id: m.mahasiswa.id, + nim: m.mahasiswa.nim, + nama: m.mahasiswa.nama, + })), + }; + + return mapped; + } + + async create(createDto: CreateKelasDto): Promise<IdKelasResDto> { + const currPeriod = await this.konfService.getPeriodeOrFail(); + + let nomor = createDto.nomor; + if (nomor) { + const checkClassQueary = this.kelasRepo + .createQueryBuilder("kelas") + .where("kelas.nomor = :nomor", { nomor }) + .andWhere("kelas.mataKuliahKode = :mataKuliahKode", { + mataKuliahKode: createDto.mataKuliahKode, + }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + const checkClass = await checkClassQueary.getOne(); + + if (checkClass) { + throw new BadRequestException(`Kelas dengan nomor ${nomor} sudah ada`); + } + } else { + nomor = await this.getNextNomorKelas(createDto.mataKuliahKode); + } + + const colorIdx = Math.floor(Math.random() * CARD_COLORS.length); + const kelas = this.kelasRepo.create({ ...createDto, - nomor: num, + nomor, periode: currPeriod, - warna: "gray", // TODO: random color, maybe need some adjustment for tailwind dynamic binding + warna: CARD_COLORS[colorIdx], }); + + try { + await this.kelasRepo.save(kelas); + } catch { + throw new InternalServerErrorException("Gagal membuat kelas baru"); + } + + return { + id: kelas.id, + }; } async createMatkul(createDto: MataKuliah) { @@ -125,7 +286,11 @@ export class KelasService { return { kode: createDto.kode }; } - async getKelasPengguna(mode: "MAHASISWA" | "DOSEN", search?: string) { + async getKelasPengguna( + mode: "MAHASISWA" | "DOSEN", + search?: string, + id?: string, + ) { const currPeriod = await this.konfService.getKonfigurasiByKey( process.env.KONF_PERIODE_KEY, ); @@ -148,6 +313,7 @@ export class KelasService { periode: currPeriod, }, ) + .leftJoinAndSelect("kelas.mataKuliah", "mataKuliah") .where("pengguna.roles @> :role", { role: [mode === "MAHASISWA" ? "S2_MAHASISWA" : "S2_KULIAH"], }); @@ -161,6 +327,10 @@ export class KelasService { ); } + if (id) { + penggunaQuery = penggunaQuery.andWhere("pengguna.id = :id", { id }); + } + const mhs = await penggunaQuery.getMany(); return mhs.map((m) => ({ @@ -171,6 +341,7 @@ export class KelasService { id: k.kelas.id, nomor: k.kelas.nomor, mataKuliahKode: k.kelas.mataKuliahKode, + mataKuliahNama: k.kelas.mataKuliah.nama, })), })); } @@ -361,4 +532,177 @@ export class KelasService { return { message: "Kelas berhasil dihapus" }; } + + async updateOrCreate(dto: UpdateKelasDto): Promise<IdKelasResDto> { + const currPeriod = await this.konfService.getPeriodeOrFail(); + + if (!dto.id) { + // Create kelas + if (!dto.mataKuliahKode) { + throw new BadRequestException("Kode mata kuliah tidak boleh kosong"); + } + + return await this.create({ + mataKuliahKode: dto.mataKuliahKode, + nomor: dto.nomor, + }); + } else { + // Update kelas + const kelasQuery = this.kelasRepo + .createQueryBuilder("kelas") + .where("kelas.id = :id", { id: dto.id }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + if (dto.nomor) { + kelasQuery.andWhere("kelas.nomor = :nomor", { nomor: dto.nomor }); + } + + const kelas = await kelasQuery.getOne(); + + if (!kelas) { + throw new NotFoundException("Kelas tidak ditemukan"); + } + + try { + await this.kelasRepo.update(kelas.id, dto); + } catch { + throw new InternalServerErrorException("Gagal memperbarui kelas"); + } + + return { + id: kelas.id, + }; + } + } + + async delete(dto: DeleteKelasDto): Promise<Kelas> { + const currPeriod = await this.konfService.getPeriodeOrFail(); + + const kelasQuery = this.kelasRepo + .createQueryBuilder("kelas") + .where("kelas.nomor = :nomor", { nomor: dto.nomor }) + .andWhere("kelas.mataKuliahKode = :mataKuliahKode", { + mataKuliahKode: dto.mataKuliahKode, + }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + const kelas = await kelasQuery.getOne(); + + if (!kelas) { + throw new BadRequestException("Kelas tidak ditemukan"); + } + + try { + await this.kelasRepo.delete(kelas.id); + } catch { + throw new InternalServerErrorException("Gagal menghapus kelas"); + } + + return kelas; + } + + async getAllMatkul(): Promise<MataKuliah[]> { + return await this.mataKuliahRepo.find(); + } + + async getNextNomorKelas(kodeMatkul: string): Promise<number> { + const currPeriod = await this.konfService.getPeriodeOrFail(); + + const maxClass = await this.kelasRepo.findOne({ + where: { + mataKuliahKode: kodeMatkul, + periode: currPeriod, + }, + order: { + nomor: "DESC", + }, + }); + + return maxClass ? maxClass.nomor + 1 : 1; + } + + async updateKelas( + dto: UpdateKelasPenggunaDto, + mode: "MAHASISWA" | "DOSEN", + ): Promise<ByIdKelasDto> { + const currPeriod = await this.konfService.getKonfigurasiByKey( + process.env.KONF_PERIODE_KEY, + ); + + if (!currPeriod) { + throw new BadRequestException("Periode belum dikonfigurasi"); + } + + const queryRunner = this.datasource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + if (mode === "MAHASISWA") { + const currKelasQuery = queryRunner.manager + .createQueryBuilder(MahasiswaKelas, "mahasiswaKelas") + .leftJoinAndSelect("mahasiswaKelas.kelas", "kelas") + .where("mahasiswaKelas.mahasiswaId = :mhsId", { + mhsId: dto.penggunaId, + }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + const currKelas = (await currKelasQuery.getMany()).map( + (k) => k.kelasId, + ); + + // Delete all kelas mahasiswa + for (const kelasId of currKelas) { + await queryRunner.manager.delete(MahasiswaKelas, { + mahasiswaId: dto.penggunaId, + kelasId, + }); + } + + // Assign kelas mahasiswa + for (const kelasId of dto.kelasIds) { + await queryRunner.manager.insert(MahasiswaKelas, { + mahasiswaId: dto.penggunaId, + kelasId, + }); + } + } else { + const currKelasQuery = queryRunner.manager + .createQueryBuilder(PengajarKelas, "pengajarKelas") + .leftJoinAndSelect("pengajarKelas.kelas", "kelas") + .where("pengajarKelas.pengajarId = :dosenId", { + dosenId: dto.penggunaId, + }) + .andWhere("kelas.periode = :periode", { periode: currPeriod }); + + const currKelas = (await currKelasQuery.getMany()).map( + (k) => k.kelasId, + ); + + for (const kelasId of currKelas) { + await queryRunner.manager.delete(PengajarKelas, { + pengajarId: dto.penggunaId, + kelasId, + }); + } + + for (const kelasId of dto.kelasIds) { + await queryRunner.manager.insert(PengajarKelas, { + pengajarId: dto.penggunaId, + kelasId, + }); + } + } + + await queryRunner.commitTransaction(); + } catch { + await queryRunner.rollbackTransaction(); + + throw new InternalServerErrorException("Gagal mengupdate kelas pengguna"); + } finally { + await queryRunner.release(); + } + + return { id: dto.penggunaId }; + } } diff --git a/src/konfigurasi/konfigurasi.service.ts b/src/konfigurasi/konfigurasi.service.ts index 23ee9f8761d27dc8f257181c6f7d2abbf35de001..a8e7edaccbcafeeab8aa59f9039ce69ae88a21c2 100644 --- a/src/konfigurasi/konfigurasi.service.ts +++ b/src/konfigurasi/konfigurasi.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Konfigurasi } from "src/entities/konfigurasi.entity"; import { Repository } from "typeorm"; @@ -29,4 +29,16 @@ export class KonfigurasiService { return data?.value; } + + async getPeriodeOrFail() { + const currPeriod = await this.getKonfigurasiByKey( + process.env.KONF_PERIODE_KEY, + ); + + if (!currPeriod) { + throw new BadRequestException("Periode belum dikonfigurasi"); + } + + return currPeriod; + } } diff --git a/src/main.ts b/src/main.ts index 11cb941afa48d31dc673e827398bac25ee2df6c0..fdce89e3372e4a1ea2d1a7bf5006ee2054c3f487 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,13 +22,15 @@ 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") .addTag("Kelas") .addTag("Konfigurasi") + .addTag("Nilai") .addTag("Registrasi Tesis") + .addTag("Submisi Tugas") + .addTag("Tugas") .addCookieAuth(process.env.COOKIE_NAME) .addBearerAuth() .build(); diff --git a/src/middlewares/forbidden-exception.filter.ts b/src/middlewares/forbidden-exception.filter.ts index ec5ba9ddf450d25a3698c6fbc8ac20f32f1d468b..9d6de0d98638596e89d2c171d2c28dbbc145c205 100644 --- a/src/middlewares/forbidden-exception.filter.ts +++ b/src/middlewares/forbidden-exception.filter.ts @@ -13,9 +13,9 @@ export class ForbiddenExceptionFilter implements ExceptionFilter { const response = ctx.getResponse<Response>(); const status = exception.getStatus(); - response.clearCookie(process.env.COOKIE_NAME).status(status).json({ - message: "Forbidden", - status, - }); + response + .clearCookie(process.env.COOKIE_NAME) + .status(status) + .json(exception.getResponse()); } } diff --git a/src/nilai/nilai.controller.ts b/src/nilai/nilai.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bb1cb659a4ad0347e37c69df0ad85750da2d1ad --- /dev/null +++ b/src/nilai/nilai.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Patch, Query, UseGuards } from "@nestjs/common"; +import { NilaiService } from "./nilai.service"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiOkResponse, + ApiTags, +} from "@nestjs/swagger"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { + GetNilaiByMatkulQueryDto, + GetNilaiByMatkulRespDto, + UpdateNilaiDto, + UpdateNilaiRespDto, +} from "./nilai.dto"; + +@ApiBearerAuth() +@ApiCookieAuth() +@ApiTags("Nilai") +@UseGuards(CustomAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) +@Controller("nilai") +export class NilaiController { + constructor(private nilaiServ: NilaiService) {} + + @ApiOkResponse({ type: [GetNilaiByMatkulRespDto] }) + @Get() + async getNilaiByMatkul(@Query() query: GetNilaiByMatkulQueryDto) { + return this.nilaiServ.getNilaiByMatkul( + query.kode, + query.page || 1, + query.limit || 10, + query.search || "", + ); + } + + @ApiOkResponse({ type: UpdateNilaiRespDto }) + @Patch() + async updateNilai(@Body() body: UpdateNilaiDto) { + return this.nilaiServ.updateNilai(body.mahasiswaKelasIds, body.nilaiAkhir); + } +} diff --git a/src/nilai/nilai.dto.ts b/src/nilai/nilai.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b933d07468212dbfacd8fc41d441227b5084418 --- /dev/null +++ b/src/nilai/nilai.dto.ts @@ -0,0 +1,78 @@ +import { IsNumberString } from "@nestjs/class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, + MinLength, +} from "class-validator"; + +export class GetNilaiByMatkulQueryDto { + @ApiPropertyOptional({ example: "IF4031" }) + @IsOptional() + @IsString() + @MinLength(6) + @MaxLength(6) + kode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 10" }) + limit?: number; +} + +export class GetNilaiByMatkulRespDto { + @ApiProperty({ example: "IF4031" }) + mata_kuliah_kode: string; + + @ApiProperty({ example: "Pemrograman Berbasis Kerangka Kerja" }) + mata_kuliah_nama: string; + + @ApiProperty() + kelas_nomor: number; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + mahasiswa_kelas_id: string; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + mahasiswa_id: string; + + @ApiProperty() + mahasiswa_nama: string; + + @ApiProperty({ example: "13517000" }) + mahasiswa_nim: string; + + @ApiProperty() + nilai_akhir: string | null; +} + +export class UpdateNilaiRespDto { + @ApiProperty({ + type: [String], + example: ["550e8400-e29b-41d4-a716-446655440000"], + }) + @IsUUID("all", { each: true }) + mahasiswaKelasIds: string[]; +} + +export class UpdateNilaiDto extends UpdateNilaiRespDto { + @ApiPropertyOptional({ description: "undefined: assign nilai to null" }) + @IsOptional() + @IsNumber() + @IsPositive() + nilaiAkhir?: number; +} diff --git a/src/nilai/nilai.module.ts b/src/nilai/nilai.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0a7c9c19ed393373632c2a8f3f8f5c6cc5610a8 --- /dev/null +++ b/src/nilai/nilai.module.ts @@ -0,0 +1,35 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuthModule } from "src/auth/auth.module"; +import { Kelas } from "src/entities/kelas.entity"; +import { Konfigurasi } from "src/entities/konfigurasi.entity"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { KelasModule } from "src/kelas/kelas.module"; +import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; +import { NilaiController } from "./nilai.controller"; +import { NilaiService } from "./nilai.service"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { KelasService } from "src/kelas/kelas.service"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { PengajarKelas } from "src/entities/pengajarKelas.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + MahasiswaKelas, + Kelas, + MataKuliah, + Konfigurasi, + Pengguna, + PengajarKelas, + ]), + AuthModule, + KonfigurasiModule, + KelasModule, + ], + controllers: [NilaiController], + providers: [NilaiService, CustomStrategy, KelasService, KonfigurasiService], +}) +export class NilaiModule {} diff --git a/src/nilai/nilai.service.ts b/src/nilai/nilai.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..81ebba4d7151fc7b42f495d542af94057fd4f66e --- /dev/null +++ b/src/nilai/nilai.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Kelas } from "src/entities/kelas.entity"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { Brackets, In, Repository } from "typeorm"; +import { GetNilaiByMatkulRespDto, UpdateNilaiRespDto } from "./nilai.dto"; + +@Injectable() +export class NilaiService { + constructor( + @InjectRepository(MahasiswaKelas) + private mhsKelasRepo: Repository<MahasiswaKelas>, + @InjectRepository(Kelas) + private kelasRepo: Repository<Kelas>, + @InjectRepository(MataKuliah) + private mataKuliahRepo: Repository<MataKuliah>, + private konfServ: KonfigurasiService, + ) {} + + private async isMhsKelasOrFail(mhsKelasIds: string[]) { + const periode = await this.konfServ.getPeriodeOrFail(); + + const mhsKelas = await this.mhsKelasRepo.find({ + select: { id: true }, + where: { id: In(mhsKelasIds), kelas: { periode } }, + }); + + for (const mhsKelasId of mhsKelasIds) { + if (!mhsKelas.find((mk) => mk.id === mhsKelasId)) { + throw new Error(`Mahasiswa kelas ${mhsKelasId} tidak ditemukan`); + } + } + } + + async getNilaiByMatkul( + mataKuliahKode: string, + page: number, + limit: number, + search: string, + ) { + const currPeriode = await this.konfServ.getPeriodeOrFail(); + console.log(limit); + + const baseQuery = this.mataKuliahRepo + .createQueryBuilder("matkul") + .select([ + "matkul.kode AS mata_kuliah_kode", + "matkul.nama AS mata_kuliah_nama", + "kelas.nomor AS kelas_nomor", + "mhsKelas.id AS mahasiswa_kelas_id", + "mahasiswa.id AS mahasiswa_id", + "mahasiswa.nama AS mahasiswa_nama", + "mahasiswa.nim AS mahasiswa_nim", + "mhsKelas.nilaiAkhir AS nilai_akhir", + ]) + .innerJoin("matkul.kelas", "kelas") + .leftJoin("kelas.mahasiswa", "mhsKelas") + .innerJoin("mhsKelas.mahasiswa", "mahasiswa") + .where("kelas.periode = :periode", { periode: currPeriode }) + .andWhere( + new Brackets((qb) => { + qb.where("mahasiswa.nama ILIKE :search", { + search: `%${search}%`, + }).orWhere("mahasiswa.nim ILIKE :search", { search: `%${search}%` }); + }), + ); + + if (mataKuliahKode) { + baseQuery.andWhere("matkul.kode = :kode", { kode: mataKuliahKode }); + } + + const mhsKelas: GetNilaiByMatkulRespDto[] = await baseQuery + .orderBy("matkul.kode") + .addOrderBy("kelas.nomor") + .addOrderBy("mahasiswa.nim") + .skip((page - 1) * limit) + .limit(limit) + .getRawMany(); + + return mhsKelas; + } + + async updateNilai( + mhsKelasIds: string[], + nilaiAkhir?: number, + ): Promise<UpdateNilaiRespDto> { + await this.isMhsKelasOrFail(mhsKelasIds); + + await this.mhsKelasRepo.update( + { id: In(mhsKelasIds) }, + { nilaiAkhir: nilaiAkhir ?? null }, + ); + + return { mahasiswaKelasIds: mhsKelasIds }; + } +} 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; } } diff --git a/src/submisi-tugas/submisi-tugas.controller.ts b/src/submisi-tugas/submisi-tugas.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb4f45a1b1cdb0d5535344ac9d8ccee62e4db8b9 --- /dev/null +++ b/src/submisi-tugas/submisi-tugas.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { SubmisiTugasService } from "./submisi-tugas.service"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { + CreateSubmisiTugasDto, + GetSubmisiTugasByIdRespDto, + GetSubmisiTugasByTugasIdQueryDto, + GetSubmisiTugasByTugasIdRespDto, + SubmisiTugasIdDto, +} from "./submisi-tugas.dto"; +import { AuthDto } from "src/auth/auth.dto"; +import { Request } from "express"; + +@ApiTags("Submisi Tugas") +@ApiBearerAuth() +@ApiCookieAuth() +@UseGuards(CustomAuthGuard, RolesGuard) +@Controller("submisi-tugas") +export class SubmisiTugasController { + constructor(private submisiTugasServ: SubmisiTugasService) {} + + @Roles(RoleEnum.S2_MAHASISWA) + @Post() + async createSubmisiTugas( + @Body() createDto: CreateSubmisiTugasDto, + @Req() req: Request, + ) { + // TODO: More validation + const { id } = req.user as AuthDto; + + return await this.submisiTugasServ.createSubmisiTugas(createDto, id); + } + + @ApiOperation({ + summary: + "Get submisi tugas by sumbisi tugas id. Roles: S2_KULIAH, S2_MAHASISWA", + }) + @ApiOkResponse({ type: GetSubmisiTugasByIdRespDto }) + @Roles(RoleEnum.S2_KULIAH, RoleEnum.S2_MAHASISWA) + @Get("/:id") + async getSubmisiTugasById( + @Req() req: Request, + @Param() param: SubmisiTugasIdDto, + ) { + const { id, roles } = req.user as AuthDto; + + let idMahasiswa = undefined; + let idPengajar = undefined; + + if (!roles.includes(RoleEnum.S2_KULIAH)) { + idMahasiswa = id; + } else { + idPengajar = id; + } + + return await this.submisiTugasServ.getSubmisiTugasById( + param.id, + idMahasiswa, + idPengajar, + ); + } + + @ApiOperation({ + summary: "Get list of submisi tugas summary by tugas id. Roles: S2_KULIAH", + }) + @ApiOkResponse({ type: [GetSubmisiTugasByTugasIdRespDto] }) + @Roles(RoleEnum.S2_KULIAH) + @Get() + async getSubmisiTugasByTugasId( + @Req() req: Request, + @Query() query: GetSubmisiTugasByTugasIdQueryDto, + ) { + const { id } = req.user as AuthDto; + + return await this.submisiTugasServ.getSubmisiTugasByTugasId( + query.tugasId, + id, + query.search || "", + query.page || 1, + query.limit || 10, + query.order || "ASC", + query.isSubmitted, + ); + } +} diff --git a/src/submisi-tugas/submisi-tugas.dto.ts b/src/submisi-tugas/submisi-tugas.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..daa8486087d0e417d1ef0c72f9b74e8b8cf15900 --- /dev/null +++ b/src/submisi-tugas/submisi-tugas.dto.ts @@ -0,0 +1,126 @@ +import { IsString } from "@nestjs/class-validator"; +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsNumberString, + IsOptional, + ValidateNested, +} from "class-validator"; +import { BerkasSubmisiTugas } from "src/entities/berkasSubmisiTugas.entity"; +import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { SubmisiTugas } from "src/entities/submisiTugas.entity"; +import { GetTugasByIdRespDto } from "src/tugas/tugas.dto"; + +class BerkasSubmisiTugasWithoutId extends OmitType(BerkasSubmisiTugas, [ + "id", +] as const) {} + +export class CreateSubmisiTugasDto extends PickType(SubmisiTugas, [ + "jawaban", + "isSubmitted", + "tugasId", +]) { + @ApiProperty({ type: [BerkasSubmisiTugasWithoutId] }) + @ValidateNested({ each: true }) + @Type(() => BerkasSubmisiTugasWithoutId) + berkasSubmisiTugas: BerkasSubmisiTugasWithoutId[]; +} + +export class SubmisiTugasIdDto extends PickType(SubmisiTugas, [ + "id", +] as const) {} + +export class GetSubmisiTugasByTugasIdQueryDto extends PickType(SubmisiTugas, [ + "tugasId", +] as const) { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 10" }) + limit?: number; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true") + @ApiPropertyOptional({ + description: "if not specified, will return all submisi tugas", + }) + isSubmitted?: boolean; + + @ApiPropertyOptional({ + enum: ["ASC", "DESC"], + description: "order by nim. default: ASC", + }) + @IsOptional() + @IsEnum(["ASC", "DESC"]) + order?: "ASC" | "DESC"; +} + +class PickedSubmisiTugas extends PickType(SubmisiTugas, [ + "id", + "isSubmitted", + "berkasSubmisiTugas", +] as const) {} + +class PickedSubmisiTugasExtended extends PickType(SubmisiTugas, [ + "id", + "isSubmitted", + "jawaban", + "submittedAt", + "berkasSubmisiTugas", +] as const) {} + +class PickedPendaftaranTesis extends PickType(PendaftaranTesis, [ + "id", + "jalurPilihan", + "waktuPengiriman", + "jadwalInterview", + "status", + "topik", +] as const) {} + +class PickedPendaftaran extends PickType(Pengguna, [ + "id", + "nama", + "email", +] as const) { + @ApiProperty({ type: PickedPendaftaranTesis }) + pendaftaranTesis: PickedPendaftaranTesis; +} + +export class GetSubmisiTugasByTugasIdRespDto extends PickType(Pengguna, [ + "id", + "nim", + "nama", +] as const) { + @ApiPropertyOptional({ type: PickedSubmisiTugas }) + submisiTugas?: PickedSubmisiTugas; +} + +export class GetSubmisiTugasByIdRespDto { + @ApiProperty({ type: GetTugasByIdRespDto }) + tugas: GetTugasByIdRespDto; + + @ApiPropertyOptional({ type: PickedPendaftaran }) + pendaftaran?: PickedPendaftaran; + + @ApiProperty({ type: PickedSubmisiTugasExtended }) + submisiTugas: PickedSubmisiTugasExtended; +} diff --git a/src/submisi-tugas/submisi-tugas.module.ts b/src/submisi-tugas/submisi-tugas.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fd15428a499fe276ed0aeaef1b8168bd532b105 --- /dev/null +++ b/src/submisi-tugas/submisi-tugas.module.ts @@ -0,0 +1,31 @@ +import { Module } from "@nestjs/common"; +import { SubmisiTugasController } from "./submisi-tugas.controller"; +import { SubmisiTugasService } from "./submisi-tugas.service"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuthModule } from "src/auth/auth.module"; +import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; +import { SubmisiTugas } from "src/entities/submisiTugas.entity"; +import { BerkasSubmisiTugas } from "src/entities/berkasSubmisiTugas.entity"; +import { TugasModule } from "src/tugas/tugas.module"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { Tugas } from "src/entities/tugas.entity"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SubmisiTugas, + BerkasSubmisiTugas, + Pengguna, + Tugas, + MahasiswaKelas, + ]), + AuthModule, + KonfigurasiModule, + TugasModule, + ], + controllers: [SubmisiTugasController], + providers: [SubmisiTugasService, CustomStrategy], +}) +export class SubmisiTugasModule {} diff --git a/src/submisi-tugas/submisi-tugas.service.ts b/src/submisi-tugas/submisi-tugas.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3c1c11a98b638aecbf96c5b67c1b8326073ef93 --- /dev/null +++ b/src/submisi-tugas/submisi-tugas.service.ts @@ -0,0 +1,257 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BerkasSubmisiTugas } from "src/entities/berkasSubmisiTugas.entity"; +import { SubmisiTugas } from "src/entities/submisiTugas.entity"; +import { TugasService } from "src/tugas/tugas.service"; +import { Brackets, Repository } from "typeorm"; +import { + CreateSubmisiTugasDto, + GetSubmisiTugasByIdRespDto, + GetSubmisiTugasByTugasIdRespDto, +} from "./submisi-tugas.dto"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { Tugas } from "src/entities/tugas.entity"; +import { RegStatus } from "src/entities/pendaftaranTesis.entity"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; + +@Injectable() +export class SubmisiTugasService { + constructor( + @InjectRepository(SubmisiTugas) + private submisiTugasRepo: Repository<SubmisiTugas>, + @InjectRepository(BerkasSubmisiTugas) + private berkasSubmisiTugasRepo: Repository<BerkasSubmisiTugas>, + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + @InjectRepository(Tugas) + private tugasRepo: Repository<Tugas>, + @InjectRepository(MahasiswaKelas) + private mahasiswaKelasRepo: Repository<MahasiswaKelas>, + private tugasService: TugasService, + private konfService: KonfigurasiService, + ) {} + private async isMahasiswaSubmisiTugasOrFail( + submisiTugasId: string, + mahasiswaId: string, + ) { + const submisiTugas = await this.submisiTugasRepo.findOne({ + where: { id: submisiTugasId }, + }); + + if (!submisiTugas) { + throw new NotFoundException("Submisi tugas tidak ditemukan"); + } + + if (submisiTugas.mahasiswaId !== mahasiswaId) { + throw new ForbiddenException("Anda tidak memiliki akses"); + } + + // validate periode + await this.tugasService.isMahasiswaTugasOrFail( + mahasiswaId, + submisiTugas.tugasId, + ); + } + + private async isPengajarSubmisiTugasOrFail( + submisiTugasId: string, + pengajarId: string, + ) { + const submisiTugas = await this.submisiTugasRepo.findOne({ + where: { id: submisiTugasId }, + relations: ["tugas"], + }); + + if (!submisiTugas) { + throw new NotFoundException("Submisi tugas tidak ditemukan"); + } + + await this.tugasService.isPengajarTugasOrFail( + pengajarId, + submisiTugas.tugas.id, + ); + } + + async createSubmisiTugas( + createDto: CreateSubmisiTugasDto, + mahasiswaId: string, + ) { + await this.tugasService.isMahasiswaTugasOrFail( + mahasiswaId, + createDto.tugasId, + ); + + const tugas = await this.tugasRepo.findOneBy({ id: createDto.tugasId }); + const mahasiswa = await this.penggunaRepo.findOneBy({ id: mahasiswaId }); + + const berkasSubmisiTugas = createDto.berkasSubmisiTugas.map( + (berkasSubmisiTugas) => + this.berkasSubmisiTugasRepo.create(berkasSubmisiTugas), + ); + + const submisiTugas = this.submisiTugasRepo.create({ + ...createDto, + mahasiswa, + submittedAt: createDto.isSubmitted ? new Date() : null, + tugas, + berkasSubmisiTugas, + }); + + await this.submisiTugasRepo.save(submisiTugas); + + return submisiTugas; + } + + private async getSubmisiTugas(id: string) { + const submisiTugas = await this.submisiTugasRepo.findOne({ + where: { id }, + relations: ["berkasSubmisiTugas"], + }); + + if (!submisiTugas) { + throw new NotFoundException("Submisi tugas tidak ditemukan"); + } + + return submisiTugas; + } + + async getSubmisiTugasById( + id: string, + mahasiswaId?: string, + pengajarId?: string, + ) { + if (mahasiswaId) { + await this.isMahasiswaSubmisiTugasOrFail(id, mahasiswaId); + } + + if (pengajarId) { + await this.isPengajarSubmisiTugasOrFail(id, pengajarId); + } + + const currPeriod = await this.konfService.getPeriodeOrFail(); + const submisiTugas = await this.getSubmisiTugas(id); + + const pendaftaranQuery = this.penggunaRepo + .createQueryBuilder("pengguna") + .select([ + "pengguna.id", + "pengguna.nama", + "pengguna.email", + "pendaftaranTesis.jalurPilihan", + "pendaftaranTesis.waktuPengiriman", + "topik.id", + "topik.judul", + "topik.deskripsi", + ]) + .leftJoinAndSelect("pengguna.pendaftaranTesis", "pendaftaranTesis") + .leftJoinAndSelect("pendaftaranTesis.topik", "topik") + .where("pengguna.id = :id", { id: submisiTugas.mahasiswaId }) + .andWhere("pendaftaranTesis.status = :status", { + status: RegStatus.APPROVED, + }) + .andWhere("topik.periode = :periode", { periode: currPeriod }) + .getOne(); + + const [tugas, pendaftaran] = await Promise.all([ + this.tugasService.getTugasById(submisiTugas.tugasId), + pendaftaranQuery, + ]); + + const result: GetSubmisiTugasByIdRespDto = { + tugas, + submisiTugas, + pendaftaran: { + ...pendaftaran, + pendaftaranTesis: + pendaftaran.pendaftaranTesis.length > 0 + ? pendaftaran.pendaftaranTesis[0] + : undefined, + }, + }; + + return result; + } + + async getSubmisiTugasByTugasId( + tugasId: string, + idPenerima: string, + search: string, + page: number, + limit: number, + order: "ASC" | "DESC", + isSubmitted?: boolean, + ) { + await this.tugasService.isPengajarTugasOrFail(idPenerima, tugasId); + + const baseQuery = await this.mahasiswaKelasRepo + .createQueryBuilder("mk") + .innerJoin("mk.kelas", "kelas", "kelas.id = mk.kelasId") + .innerJoinAndSelect("mk.mahasiswa", "mahasiswa") + .leftJoinAndSelect( + "mahasiswa.submisiTugas", + "submisiTugas", + "submisiTugas.tugasId = :tugasId", + { tugasId }, + ) + .leftJoinAndSelect( + "submisiTugas.berkasSubmisiTugas", + "berkasSubmisiTugas", + ) + .select([ + "mk.id", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "submisiTugas.id", + "submisiTugas.isSubmitted", + "berkasSubmisiTugas", + ]) + .distinctOn(["mahasiswa.nim"]) + .where( + new Brackets((qb) => { + qb.where("mahasiswa.nama ILIKE :search", { + search: `%${search}%`, + }).orWhere("mahasiswa.nim ILIKE :search", { search: `%${search}%` }); + }), + ) + .orderBy("mahasiswa.nim", order); + + if (isSubmitted !== undefined) { + if (isSubmitted) { + baseQuery.andWhere("submisiTugas.isSubmitted = true"); + } else { + baseQuery.andWhere( + new Brackets((qb) => + qb + .where("submisiTugas.isSubmitted <> true") + .orWhere("submisiTugas.isSubmitted IS NULL"), + ), + ); + } + } + + const submisiTugas = await baseQuery + .limit(limit) + .skip((page - 1) * limit) + .getMany(); + + const mappedResult: GetSubmisiTugasByTugasIdRespDto[] = submisiTugas.map( + (submisi) => ({ + id: submisi.mahasiswa.id, + nim: submisi.mahasiswa.nim, + nama: submisi.mahasiswa.nama, + submisiTugas: + submisi.mahasiswa.submisiTugas.length > 0 + ? submisi.mahasiswa.submisiTugas[0] + : undefined, + }), + ); + + return mappedResult; + } +} diff --git a/src/tugas/tugas.controller.ts b/src/tugas/tugas.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..b66f4efe167c7a14fea84ac9c710f4f559155186 --- /dev/null +++ b/src/tugas/tugas.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { TugasService } from "./tugas.service"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { + TugasIdDto, + CreateTugasDto, + UpdateTugasDto, + GetTugasByIdRespDto, + GetTugasByKelasIdQueryDto, + GetTugasByKelasIdRespDto, +} from "./tugas.dto"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; + +@ApiCookieAuth() +@ApiBearerAuth() +@ApiTags("Tugas") +@UseGuards(CustomAuthGuard, RolesGuard) +@Controller("tugas") +export class TugasController { + constructor(private readonly tugasService: TugasService) {} + + @ApiOperation({ summary: "Create Tugas. Roles: S2_KULIAH" }) + @ApiCreatedResponse({ type: TugasIdDto }) + @Roles(RoleEnum.S2_KULIAH) + @Post() + async createTugas(@Body() createDto: CreateTugasDto, @Req() req: Request) { + const { id } = req.user as AuthDto; + + return await this.tugasService.createTugas(createDto, id); + } + + @ApiOperation({ summary: "Update Tugas. Roles: S2_KULIAH" }) + @ApiOkResponse({ type: TugasIdDto }) + @Roles(RoleEnum.S2_KULIAH) + @Put() + async updateTugas(@Body() updateDto: UpdateTugasDto, @Req() req: Request) { + const { id } = req.user as AuthDto; + + return await this.tugasService.updateTugasById(updateDto, id); + } + + @ApiOperation({ + summary: "Get Tugas by id. Roles: S2_KULIAH, S2_MAHASISWA", + }) + @ApiOkResponse({ type: GetTugasByIdRespDto }) + @Roles(RoleEnum.S2_KULIAH, RoleEnum.S2_MAHASISWA) + @Get("/:id") + async getTugasById(@Param() param: TugasIdDto, @Req() req: Request) { + let idPengajar = undefined; + let idMahasiswa = undefined; + + const { id, roles } = req.user as AuthDto; + + if (!roles.includes(RoleEnum.S2_KULIAH)) { + idMahasiswa = id; + } else { + idPengajar = id; + } + + return this.tugasService.getTugasById(param.id, idMahasiswa, idPengajar); + } + + @ApiOperation({ + summary: "Get Tugas list by kelas id. Roles: S2_KULIAH", + }) + @ApiOkResponse({ type: GetTugasByKelasIdRespDto }) + @Roles(RoleEnum.S2_KULIAH) + @Get() + async getTugasByKelasId( + @Req() req: Request, + @Query() query: GetTugasByKelasIdQueryDto, + ) { + const { id } = req.user as AuthDto; + + return this.tugasService.getTugasByKelasId( + query.kelasId, + id, + query.search || "", + query.page || 1, + query.limit || 10, + ); + } +} diff --git a/src/tugas/tugas.dto.ts b/src/tugas/tugas.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..afbb6eff67825b726e20169db6dee9e73d79f0ce --- /dev/null +++ b/src/tugas/tugas.dto.ts @@ -0,0 +1,109 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsNumberString, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator"; +import { BerkasTugas } from "src/entities/berkasTugas.entity"; +import { Kelas } from "src/entities/kelas.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { Tugas } from "src/entities/tugas.entity"; +import { GetKelasRespDto } from "src/kelas/kelas.dto"; + +class BerkasTugasWithoutId extends OmitType(BerkasTugas, ["id"] as const) {} + +export class CreateTugasDto extends PickType(Tugas, [ + "judul", + "waktuMulai", + "waktuSelesai", + "deskripsi", + "kelasId", +]) { + @ApiProperty({ type: [BerkasTugasWithoutId] }) + @ValidateNested({ each: true }) + @Type(() => BerkasTugasWithoutId) + berkasTugas: BerkasTugasWithoutId[]; +} + +export class UpdateTugasDto extends OmitType(CreateTugasDto, [ + "kelasId", +] as const) { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + id: string; +} + +export class TugasIdDto extends PickType(Tugas, ["id"] as const) {} + +class PickedPengajarKelas extends PickType(Pengguna, ["id", "nama"] as const) {} + +class PickedTugasKelas extends PickType(Kelas, [ + "id", + "nomor", + "mataKuliah", +] as const) {} + +export class GetTugasByIdRespDto extends PickType(Tugas, [ + "id", + "judul", + "waktuMulai", + "waktuSelesai", + "deskripsi", + "createdAt", + "updatedAt", + "berkasTugas", +] as const) { + @ApiProperty({ type: PickedPengajarKelas }) + pembuat: PickedPengajarKelas; + + @ApiProperty({ type: PickedPengajarKelas }) + pengubah: PickedPengajarKelas; + + @ApiProperty({ type: PickedTugasKelas }) + kelas: PickedTugasKelas; +} + +export class GetTugasByKelasIdQueryDto extends PickType(Tugas, [ + "kelasId", +] as const) { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 10" }) + limit?: number; +} + +export class GetTugasSummaryRespDto extends PickType(Tugas, [ + "id", + "judul", + "waktuMulai", + "waktuSelesai", +] as const) { + @ApiProperty() + totalSubmisi: number; +} + +export class GetTugasByKelasIdRespDto { + @ApiProperty({ type: [GetTugasSummaryRespDto] }) + tugas: GetTugasSummaryRespDto[]; + + @ApiProperty({ type: GetKelasRespDto }) + kelas: GetKelasRespDto; +} diff --git a/src/tugas/tugas.module.ts b/src/tugas/tugas.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a8d49258c546ffaa9d9d95014f4d10af9190442 --- /dev/null +++ b/src/tugas/tugas.module.ts @@ -0,0 +1,44 @@ +import { Module } from "@nestjs/common"; +import { TugasController } from "./tugas.controller"; +import { TugasService } from "./tugas.service"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { PengajarKelas } from "src/entities/pengajarKelas.entity"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; +import { Tugas } from "src/entities/tugas.entity"; +import { SubmisiTugas } from "src/entities/submisiTugas.entity"; +import { AuthModule } from "src/auth/auth.module"; +import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { BerkasTugas } from "src/entities/berkasTugas.entity"; +import { BerkasSubmisiTugas } from "src/entities/berkasSubmisiTugas.entity"; +import { Kelas } from "src/entities/kelas.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { KelasModule } from "src/kelas/kelas.module"; +import { KelasService } from "src/kelas/kelas.service"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { Konfigurasi } from "src/entities/konfigurasi.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + PengajarKelas, + MahasiswaKelas, + Tugas, + SubmisiTugas, + BerkasTugas, + BerkasSubmisiTugas, + Kelas, + Pengguna, + MataKuliah, + Konfigurasi, + ]), + AuthModule, + KonfigurasiModule, + KelasModule, + ], + controllers: [TugasController], + providers: [TugasService, CustomStrategy, KelasService, KonfigurasiService], + exports: [TugasService], +}) +export class TugasModule {} diff --git a/src/tugas/tugas.service.ts b/src/tugas/tugas.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f99625c045bdc011de895afd60bd01371658ecb --- /dev/null +++ b/src/tugas/tugas.service.ts @@ -0,0 +1,277 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Tugas } from "src/entities/tugas.entity"; +import { Repository } from "typeorm"; +import { + CreateTugasDto, + GetTugasByIdRespDto, + GetTugasByKelasIdRespDto, + GetTugasSummaryRespDto, + TugasIdDto, + UpdateTugasDto, +} from "./tugas.dto"; +import { BerkasTugas } from "src/entities/berkasTugas.entity"; +import { SubmisiTugas } from "src/entities/submisiTugas.entity"; +import { PengajarKelas } from "src/entities/pengajarKelas.entity"; +import { MahasiswaKelas } from "src/entities/mahasiswaKelas.entity"; +import { Kelas } from "src/entities/kelas.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { KelasService } from "src/kelas/kelas.service"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import * as dayjs from "dayjs"; + +@Injectable() +export class TugasService { + constructor( + @InjectRepository(Tugas) private tugasRepo: Repository<Tugas>, + @InjectRepository(BerkasTugas) + private berkasTugasRepo: Repository<BerkasTugas>, + @InjectRepository(SubmisiTugas) + private submisiTugasRepo: Repository<SubmisiTugas>, + @InjectRepository(PengajarKelas) + private doskelRepo: Repository<PengajarKelas>, + @InjectRepository(MahasiswaKelas) + private mahasiswaKelasRepo: Repository<MahasiswaKelas>, + @InjectRepository(Kelas) + private kelasRepo: Repository<Kelas>, + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + private kelasService: KelasService, + private konfService: KonfigurasiService, + ) {} + + private async isPengajarKelasOrFail(pengajarId: string, kelasId: string) { + const periode = await this.konfService.getPeriodeOrFail(); + + const doskel = await this.doskelRepo.findOne({ + where: { + pengajarId, + kelasId, + kelas: { + periode, + }, + }, + relations: ["kelas"], + }); + + if (!doskel) { + throw new ForbiddenException("Anda tidak memiliki akses"); + } + } + + private async isMahasiswaKelasOrFail(mahasiswaId: string, kelasId: string) { + const periode = await this.konfService.getPeriodeOrFail(); + + const mahasiswaKelas = await this.mahasiswaKelasRepo.findOne({ + where: { + mahasiswaId, + kelasId, + kelas: { + periode, + }, + }, + relations: ["kelas"], + }); + + if (!mahasiswaKelas) { + throw new ForbiddenException("Anda tidak memiliki akses"); + } + } + + async isPengajarTugasOrFail(pengajarId: string, tugasId: string) { + const tugas = await this.tugasRepo.findOne({ + where: { id: tugasId }, + }); + + if (!tugas) { + throw new NotFoundException("Tugas tidak ditemukan"); + } + + await this.isPengajarKelasOrFail(pengajarId, tugas.kelasId); + } + + async isMahasiswaTugasOrFail(mahasiswaId: string, tugasId: string) { + const tugas = await this.tugasRepo.findOne({ + where: { id: tugasId }, + }); + + if (!tugas) { + throw new NotFoundException("Tugas tidak ditemukan"); + } + + return await this.isMahasiswaKelasOrFail(mahasiswaId, tugas.kelasId); + } + + private async getTugas(tugasId: string): Promise<GetTugasByIdRespDto> { + const result: GetTugasByIdRespDto[] = await this.tugasRepo + .createQueryBuilder("tugas") + .leftJoinAndSelect("tugas.pembuat", "pembuat") + .leftJoinAndSelect("tugas.pengubah", "pengubah") + .leftJoinAndSelect("tugas.kelas", "kelas") + .leftJoinAndSelect("kelas.mataKuliah", "mataKuliah") + .leftJoinAndSelect("tugas.berkasTugas", "berkasTugas") + .select([ + "tugas.id", + "pembuat.id", + "pembuat.nama", + "pengubah.id", + "pengubah.nama", + "tugas.judul", + "tugas.waktuMulai", + "tugas.waktuSelesai", + "tugas.deskripsi", + "tugas.createdAt", + "tugas.updatedAt", + "berkasTugas", + "kelas.id", + "kelas.nomor", + "mataKuliah.kode", + "mataKuliah.nama", + ]) + .where("tugas.id = :tugasId", { tugasId }) + .getMany(); + + return result[0]; + } + + async createTugas( + createDto: CreateTugasDto, + pembuatId: string, + ): Promise<TugasIdDto> { + await this.isPengajarKelasOrFail(pembuatId, createDto.kelasId); + + if (dayjs(createDto.waktuMulai).isAfter(dayjs(createDto.waktuSelesai))) { + throw new BadRequestException( + "Waktu mulai tidak boleh setelah waktu selesai", + ); + } + + const kelas = await this.kelasRepo.findOne({ + where: { id: createDto.kelasId }, + }); + + const pembuat = await this.penggunaRepo.findOne({ + where: { id: pembuatId }, + }); + + const berkasTugas = createDto.berkasTugas.map((berkas) => + this.berkasTugasRepo.create(berkas), + ); + + const tugas = this.tugasRepo.create({ + ...createDto, + kelas, + pembuat, + pengubah: pembuat, + berkasTugas, + }); + + const result = await this.tugasRepo.save(tugas); + + return { id: result.id }; + } + + async updateTugasById( + updateDto: UpdateTugasDto, + pengubahId: string, + ): Promise<TugasIdDto> { + await this.isPengajarTugasOrFail(pengubahId, updateDto.id); + + if (dayjs(updateDto.waktuMulai).isAfter(dayjs(updateDto.waktuSelesai))) { + throw new BadRequestException( + "Waktu mulai tidak boleh setelah waktu selesai", + ); + } + + const berkasTugas = updateDto.berkasTugas.map((berkas) => + this.berkasTugasRepo.create(berkas), + ); + + const pengubah = await this.penggunaRepo.findOne({ + where: { id: pengubahId }, + }); + + const prevTugas = await this.tugasRepo.findOne({ + where: { id: updateDto.id }, + relations: ["kelas", "pembuat"], + }); + + const data = { + ...updateDto, + updatedAt: new Date(), + pengubah, + kelas: prevTugas.kelas, + pembuat: prevTugas.pembuat, + berkasTugas, + }; + + await this.tugasRepo.save(data); + + return { id: updateDto.id }; + } + + async getTugasById(id: string, idMahasiswa?: string, idPengajar?: string) { + if (idMahasiswa) { + await this.isMahasiswaTugasOrFail(idMahasiswa, id); + } + + if (idPengajar) { + await this.isPengajarTugasOrFail(idPengajar, id); + } + + const result = await this.getTugas(id); + + return result; + } + + async getTugasByKelasId( + kelasId: string, + idPengajar: string, + search: string, + page: number, + limit: number, + ): Promise<GetTugasByKelasIdRespDto> { + await this.isPengajarKelasOrFail(idPengajar, kelasId); + + const kelasQuery = this.kelasService.getById(kelasId); + const tugasQuery = await this.tugasRepo + .createQueryBuilder("tugas") + .leftJoinAndSelect( + "tugas.submisiTugas", + "submisi_tugas", + "submisi_tugas.isSubmitted = true", + ) + .select([ + "tugas.id AS id", + "tugas.judul AS judul", + "tugas.waktuMulai AS waktu_mulai", + "tugas.waktuSelesai AS waktu_selesai", + "COUNT(submisi_tugas) AS total_submisi", + ]) + .where("tugas.kelasId = :kelasId", { + kelasId, + }) + .andWhere("tugas.judul ILIKE :search", { search: `%${search}%` }) + .groupBy("tugas.id") + .orderBy("tugas.createdAt", "DESC") + .limit(limit) + .skip((page - 1) * limit) + .getRawMany(); + + const [kelas, tugas] = await Promise.all([kelasQuery, tugasQuery]); + const mappedTugas: GetTugasSummaryRespDto[] = tugas.map((tugas) => ({ + id: tugas.id, + judul: tugas.judul, + waktuMulai: tugas.waktu_mulai, + waktuSelesai: tugas.waktu_selesai, + totalSubmisi: parseInt(tugas.total_submisi), + })); + + return { kelas, tugas: mappedTugas }; + } +}