diff --git a/src/app.module.ts b/src/app.module.ts index b783ea53a60cf8640420a25453d4bf4ba6c64472..e2718653c5b8f7856d0baf4d258e4cacf378060b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,7 +8,7 @@ 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 { Ruangan } from "./entities/ruangan.entity"; @@ -23,12 +23,15 @@ import { BimbinganModule } from "./bimbingan/bimbingan.module"; import { Konfigurasi } from "./entities/konfigurasi.entity"; import { KonfigurasiModule } from "./konfigurasi/konfigurasi.module"; import { validate } from "./env.validation"; -import { BerkasBimbingan } from "./entities/berkasBimbingan"; -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"; @@ -73,6 +76,9 @@ import { DosenBimbinganModule } from "./dosen-bimbingan/dosen-bimbingan.module"; BimbinganModule, KonfigurasiModule, KelasModule, + TugasModule, + SubmisiTugasModule, + NilaiModule, DosenBimbinganModule, ], controllers: [AppController], diff --git a/src/bimbingan/bimbingan.dto.ts b/src/bimbingan/bimbingan.dto.ts index 7409b67e8c4293b1d0565e02b61883b668acde42..f3afcdac8aeeb0380633e5609bc7b35d8c864013 100644 --- a/src/bimbingan/bimbingan.dto.ts +++ b/src/bimbingan/bimbingan.dto.ts @@ -14,7 +14,7 @@ import { PickType, } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { BerkasBimbingan } from "src/entities/berkasBimbingan"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; import { Bimbingan, BimbinganStatus } from "src/entities/bimbingan.entity"; import { JalurEnum, diff --git a/src/bimbingan/bimbingan.module.ts b/src/bimbingan/bimbingan.module.ts index 25e02da5c5205c69b800de0b59e223c65948519c..30174c69ff5a33b4a5caa742095ef9f830c5eb8d 100644 --- a/src/bimbingan/bimbingan.module.ts +++ b/src/bimbingan/bimbingan.module.ts @@ -6,7 +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"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; @Module({ imports: [ diff --git a/src/bimbingan/bimbingan.service.ts b/src/bimbingan/bimbingan.service.ts index 1947b81ab45a475f276c6a8d58c4ee043d8aaec6..52c1ba28e98f9c71aad2d88e5ebeafb8d1cb1d40 100644 --- a/src/bimbingan/bimbingan.service.ts +++ b/src/bimbingan/bimbingan.service.ts @@ -24,7 +24,7 @@ import { UpdateStatusDto, UpdateStatusResDto, } from "./bimbingan.dto"; -import { BerkasBimbingan } from "src/entities/berkasBimbingan"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; @Injectable() export class BimbinganService { diff --git a/src/entities/berkasBimbingan.ts b/src/entities/berkasBimbingan.entity.ts similarity index 77% rename from src/entities/berkasBimbingan.ts rename to src/entities/berkasBimbingan.entity.ts index 6acceea0c51e5cfaa149724cb5f7824fa331544c..35b44ace3f7a70db3a1bcbd210c3a95be701c99a 100644 --- a/src/entities/berkasBimbingan.ts +++ b/src/entities/berkasBimbingan.entity.ts @@ -9,7 +9,9 @@ export class BerkasBimbingan { @PrimaryGeneratedColumn("uuid") id: string; - @ManyToOne(() => Bimbingan, (bimbingan) => bimbingan.id) + @ManyToOne(() => Bimbingan, (bimbingan) => bimbingan.id, { + orphanedRowAction: "delete", + }) bimbingan: Bimbingan; @Column({ type: "text" }) @@ -19,6 +21,6 @@ export class BerkasBimbingan { @Column({ type: "text" }) @IsUrl() - @ApiProperty() + @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 33df7a17badd1b84d408bb313c47c46fad2410b7..a846e824d3d98389e6a2402efad351f49188f9fb 100644 --- a/src/entities/bimbingan.entity.ts +++ b/src/entities/bimbingan.entity.ts @@ -7,7 +7,7 @@ import { } from "typeorm"; import { PendaftaranTesis } from "./pendaftaranTesis.entity"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { BerkasBimbingan } from "./berkasBimbingan"; +import { BerkasBimbingan } from "./berkasBimbingan.entity"; export enum BimbinganStatus { LANCAR = "LANCAR", diff --git a/src/entities/kelas.entity.ts b/src/entities/kelas.entity.ts index cc9df1e26aee879759d3260c02fa58378c7a5238..deec327ed9a9028f7eb8ddd886826f35a3294c80 100644 --- a/src/entities/kelas.entity.ts +++ b/src/entities/kelas.entity.ts @@ -6,10 +6,10 @@ import { OneToMany, PrimaryGeneratedColumn, } from "typeorm"; -import { MataKuliah } from "./mataKuliah"; +import { MataKuliah } from "./mataKuliah.entity"; import { ApiProperty } from "@nestjs/swagger"; import { PengajarKelas } from "./pengajarKelas.entity"; -import { MahasiswaKelas } from "./mahasiswaKelas"; +import { MahasiswaKelas } from "./mahasiswaKelas.entity"; import { IsPositive, IsString, @@ -17,6 +17,7 @@ import { Length, MaxLength, } from "@nestjs/class-validator"; +import { Tugas } from "./tugas.entity"; @Entity() export class Kelas { @@ -35,6 +36,7 @@ export class Kelas { @Column({ type: "text" }) periode: string; + @ApiProperty({ type: MataKuliah }) @ManyToOne(() => MataKuliah, (mataKuliah) => mataKuliah.kode) @JoinColumn({ name: "mataKuliahKode" }) mataKuliah: MataKuliah; @@ -56,4 +58,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/pendaftaranTesis.entity.ts b/src/entities/pendaftaranTesis.entity.ts index e7414aabc036cdc7d2a2a1344bcbabb1fd7ace88..637a05e71e7be84615568bcaef45f0adc2d14e2c 100644 --- a/src/entities/pendaftaranTesis.entity.ts +++ b/src/entities/pendaftaranTesis.entity.ts @@ -55,7 +55,7 @@ export class PendaftaranTesis { @Column({ type: "enum", enum: RegStatus, default: RegStatus.NOT_ASSIGNED }) status: RegStatus; - @ApiProperty() + @ApiProperty({ type: Topik }) @ManyToOne(() => Topik, (topik) => topik.id) topik: Topik; diff --git a/src/entities/pengguna.entity.ts b/src/entities/pengguna.entity.ts index 9039cf4295dcf27135972b7ba4fc82ce6206e7b6..c28a606ea4031436e36fc7958c0756a78f898cbe 100644 --- a/src/entities/pengguna.entity.ts +++ b/src/entities/pengguna.entity.ts @@ -4,7 +4,9 @@ import { ApiProperty, ApiPropertyOptional, } from "@nestjs/swagger"; -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { PendaftaranTesis } from "./pendaftaranTesis.entity"; +import { SubmisiTugas } from "./submisiTugas.entity"; export enum RoleEnum { ADMIN = "ADMIN", @@ -56,4 +58,10 @@ export class Pengguna { @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/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.controller.ts b/src/kelas/kelas.controller.ts index 88460f5dd5acbe94f91341ea795f387f2d02fe17..ae942b029bd3415ece79a2d5df0b3b4ad5befd3a 100644 --- a/src/kelas/kelas.controller.ts +++ b/src/kelas/kelas.controller.ts @@ -12,10 +12,12 @@ import { UseGuards, } from "@nestjs/common"; import { + ByIdKelasDto, CreateKelasDto, DeleteKelasDto, + GetKelasDetailRespDto, GetKelasQueryDto, - GetListKelasRespDto, + GetKelasRespDto, GetNextNomorResDto, IdKelasResDto, KodeRespDto, @@ -32,13 +34,14 @@ import { 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") @@ -49,7 +52,11 @@ import { Kelas } from "src/entities/kelas.entity"; export class KelasController { constructor(private readonly kelasServ: KelasService) {} - @ApiOkResponse({ type: GetListKelasRespDto, isArray: true }) + @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, @@ -148,4 +155,77 @@ export class KelasController { 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, + ); + } } diff --git a/src/kelas/kelas.dto.ts b/src/kelas/kelas.dto.ts index e9d6b5f5b2b7b2b265d59cf87a9ec73ba3f05033..352ae36ea1a1d0c0e45ee7e9eb6eb778cd460439 100644 --- a/src/kelas/kelas.dto.ts +++ b/src/kelas/kelas.dto.ts @@ -6,8 +6,8 @@ import { ApiPropertyOptional, } from "@nestjs/swagger"; import { Kelas } from "src/entities/kelas.entity"; -import { MataKuliah } from "src/entities/mataKuliah"; -import { RoleEnum } from "src/entities/pengguna.entity"; +import { MataKuliah } from "src/entities/mataKuliah.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; export class CreateKelasDto extends PickType(Kelas, [ "mataKuliahKode", @@ -43,18 +43,26 @@ export class GetKelasQueryDto { 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) {} @@ -63,3 +71,22 @@ 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[]; +} diff --git a/src/kelas/kelas.module.ts b/src/kelas/kelas.module.ts index 80ddae351a722bc5c4aecc32508e90fcd108c9ce..38dfa27f8690f2608b1450c134536f7d3d920651 100644 --- a/src/kelas/kelas.module.ts +++ b/src/kelas/kelas.module.ts @@ -6,7 +6,7 @@ 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"; @Module({ imports: [ diff --git a/src/kelas/kelas.service.ts b/src/kelas/kelas.service.ts index c771ac6324a2ac862cbc114275577a633d3a334b..fa62a4707fe36efeca92ef76b8074dd392e7a92b 100644 --- a/src/kelas/kelas.service.ts +++ b/src/kelas/kelas.service.ts @@ -10,12 +10,13 @@ import { Brackets, Repository } from "typeorm"; import { CreateKelasDto, DeleteKelasDto, - GetListKelasRespDto, + GetKelasDetailRespDto, + GetKelasRespDto, IdKelasResDto, UpdateKelasDto, } 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"; @Injectable() @@ -27,20 +28,13 @@ export class KelasService { private mataKuliahRepo: Repository<MataKuliah>, private konfService: KonfigurasiService, ) {} - async getListKelas( idMahasiswa?: string, idPengajar?: string, kodeMatkul?: string, search?: string, ) { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); - - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); - } + const currPeriod = await this.konfService.getPeriodeOrFail(); let baseQuery = this.kelasRepo .createQueryBuilder("kelas") @@ -49,6 +43,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", @@ -95,25 +90,143 @@ export class KelasService { .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): Promise<IdKelasResDto> { - 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(); + + 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, + }); + } + + if (idPengajar) { + baseQuery = baseQuery + .innerJoin("kelas.pengajar", "pengajar") + .andWhere("pengajar.pengajarId = :idPengajar", { + idPengajar, + }); + } + + const result = await baseQuery + .groupBy("kelas.id, mataKuliah.kode") + .getRawOne(); + + 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, + }; - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); + 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 @@ -159,13 +272,7 @@ export class KelasService { } async updateOrCreate(dto: UpdateKelasDto): Promise<IdKelasResDto> { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); - - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); - } + const currPeriod = await this.konfService.getPeriodeOrFail(); if (!dto.id) { // Create kelas @@ -207,13 +314,7 @@ export class KelasService { } async delete(dto: DeleteKelasDto): Promise<Kelas> { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); - - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); - } + const currPeriod = await this.konfService.getPeriodeOrFail(); const kelasQuery = this.kelasRepo .createQueryBuilder("kelas") @@ -243,13 +344,7 @@ export class KelasService { } async getNextNomorKelas(kodeMatkul: string): Promise<number> { - const currPeriod = await this.konfService.getKonfigurasiByKey( - process.env.KONF_PERIODE_KEY, - ); - - if (!currPeriod) { - throw new BadRequestException("Periode belum dikonfigurasi"); - } + const currPeriod = await this.konfService.getPeriodeOrFail(); const maxClass = await this.kelasRepo.findOne({ where: { 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 bb2da38e1a57d5422546e9ae52909898e1d51176..fdce89e3372e4a1ea2d1a7bf5006ee2054c3f487 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,7 +27,10 @@ async function bootstrap() { .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..f9aca923b411670865c235c946b28d698a099a52 --- /dev/null +++ b/src/nilai/nilai.module.ts @@ -0,0 +1,26 @@ +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"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MahasiswaKelas, Kelas, MataKuliah, Konfigurasi]), + 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/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 }; + } +}