diff --git a/.env.example b/.env.example index 60937fa941f657b5baedda90beed212a9592da3d..636ce5ecb1f6cbdeac73878e12263937afd06f5b 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,8 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER= POSTGRES_PASSWORD= -POSTGRES_DATABASE= \ No newline at end of file +POSTGRES_DATABASE= +AUTH_SERVICE_URL=http://localhost:3001 +FE_URL=http://localhost:5173 + +COOKIE_NAME=gradu-it.access-token diff --git a/.gitlab/merge_request_templates/base.md b/.gitlab/merge_request_templates/base.md new file mode 100644 index 0000000000000000000000000000000000000000..95e0d8dc200f0c4fd94720255c8d09e3d1607503 --- /dev/null +++ b/.gitlab/merge_request_templates/base.md @@ -0,0 +1,33 @@ +# Story/Task + +Tulis jenisnya (hapus salah 1 di atas) apakah story/task dan tulis ini berkaitan dengan sprint backlog yang mana.<br> +[nomor sprint backlog (sesuai masing-masing kelompok)] - [Task] + +# Details + +Isi dengan detail apa aja yang kalian kerjain (fitur, perubahan, dll.).<br> + +# Important Checks + +Ceklis kalo kalian mengubah/menambahkan: + +- [ ] Menambahkan `env` baru +- [ ] Mengubah skema basis data + +# Endpoints + +Isi dengan method (GET/POST/PUT/...), endpoint, dan keterangannya juga (misal: untuk menambah ... baru).<br> +| Method | Endpoint | Keterangan | +| -------- | ------------------------------------------------------ | ------------------- | +| | [/path/path](http://localhost:3000/[path/path]) | | +| | [/path/path](http://localhost:3000/[path/path]) | | +| | [/path/path](http://localhost:3000/[path/path]) | | + +# Proof + +Isi dengan screenshot dokumentasi API kalian. DOKUMENTASI HARUS DALAM BENTUK YANG DAPAT DIAKSES PADA `/api-docs`, TIDAK BOLEH MENGGUNAKAN POSTMAN/SWAGGER SENDIRI!<br> +| Method | Endpoint | Proof | +| -------- | ------------------------------------------------------ | ------------------- | +| | [/path/path](http://localhost:3000/[path/path]) | SCREENSHOT | +| | [/path/path](http://localhost:3000/[path/path]) | SCREENSHOT | +| | [/path/path](http://localhost:3000/[path/path]) | SCREENSHOT | diff --git a/README.md b/README.md index 00a13b112ad2bb1058f0581d956e7605f0b8bda0..63b887fa696e1111a6a8ff73dd9654ee2189b066 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,154 @@ -<p align="center"> - <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a> -</p> - -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest - - <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p> - <p align="center"> -<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a> -<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a> -<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a> -<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a> -<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a> -<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a> -<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a> -<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a> - <a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a> - <a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a> - <a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a> -</p> - <!--[](https://opencollective.com/nest#backer) - [](https://opencollective.com/nest#sponsor)--> - -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Installation - -```bash -$ npm install -``` +# PPL -## Running the app +## Git branching -```bash -# development -$ npm run start +Repository akan memiliki 2 branch utama, yaitu main dan development. +Setiap pembuatan branch baru, buat branch baru dengan base development. +Format: `<tipe>/<judul>` -# watch mode -$ npm run start:dev +List tipe: -# production mode -$ npm run start:prod -``` +- Story, untuk fitur atau use case baru +- Task, untuk bug fixing, performance improvement, refactor, dsb. + +Judul: gunakan kebab case + +Contoh: + +- story/api-attendance +- story/page-attendance +- task/improve-sql-performance-on-xxxx-method -## Test +Setelah selesai, Merge Request ke development dan wajib minta review ke scrum master. -```bash -# unit tests -$ npm run test +## Code Styling & Repository -# e2e tests -$ npm run test:e2e +Sangat dimohon untuk memperhatikan hal-hal berikut: + +1. Penamaan variabel, fungsi, dan kelas yang bermakna. +2. Penyingkatan harus mudah ditebak dan masih terbaca. + - Misalkan, codeStylingAndRepository, terlalu panjang, disingkat menjadi: codeStyleNRepo. + - Yang Salah: csnr, cdStNrep. +3. Membuat kelas, type, dan interface dengan pascal case (ClassName). +4. Membuat fungsi dan variable dengan camel case (fungsiDanVariabel). +5. Membuat folder dan file dengan kebab case (nama-folder). + +## Folder + +``` +src +├ entities +├ helper +├ middlewares +└ <nama-modul> + ├ <nama-modul>.controller.ts + ├ <nama-modul>.module.ts + ├ <nama-modul>.dto.ts + └ <nama-modul>.service.ts -# test coverage -$ npm run test:cov ``` -## Support +Folder menggunakan sistem modul NestJS yang bisa dilihat di https://docs.nestjs.com/modules. +Berikut merupakan penjelasan dasar dari setiap folder. + +- `src/entities` + a. Berisi entity typeORM sesuai ERD yang ada di https://app.eraser.io/workspace/z0dwTFLk5F4reT6CYK7E. + b. Atribut entity (ex: title, description) bebas ditambahkan. Jika ada atribut yang diubah + atau dihapus, infokan ke yang lain karena mungkin berpengaruh ke pengerjaan sebelumnya. + c. Jika ingin menambahkan tabel atau relasi, diskusikan dengan yang lain. + d. Jika mengubah atribut atau tabel, update ERD agar sesuai. +- `src/helper` + a. Berisi fungsi utility atau helper. +- `src/middlewares` + a. Berisi midddleware aplikasi, bisa berupa guard atau interceptor. +- `src/<nama-modul>/.module.ts` + a. Berisi konfigurasi dasar dari sebuah modul. +- `src/<nama-modul>/.controller.ts` + a. Berisi controller yang akan melakukan mapping antara endpoint dengan handler-nya. +- `src/<nama-modul>/.service.ts` + a. Berisi service yang akan menerima request dan menghasilkan response. +- `src/<nama-modul>/.dto.ts` + a. Berisi data transfer object yang mendefinisikan struktur request ataupun response. + +## Semantic Commit Message + +- `feat`: (new feature for the user, not a new feature for build script) +- `fix`: (bug fix for the user, not a fix to a build script) +- `docs`: (changes to the documentation) +- `style`: (formatting, missing semi colons, etc; no production code change) +- `refactor`: (refactoring production code, eg. renaming a variable) +- `test`: (adding missing tests, refactoring tests; no production code change) +- `chore`: (updating grunt tasks etc; no production code change) + +## Local Development Setup + +### Git + +Authorize ke github menggunakan SSH/ HTTPs. Referensi untuk SSH: + +https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent + +https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account + +### Requirements + +1. Node versi 21 + +### Instalasi Requirements + +1. Install node 21 melalui node version manager. Referensi: https://github.com/nvm-sh/nvm#installing-and-updating + +`nvm install lts/hydrogen` + +### Langkah-Langkah + +1. Clone repo `git clone git@gitlab.informatika.org:k-02-02/ppl-backend.git` atau `git clone https://gitlab.informatika.org/k-02-02/ppl-backend.git` +2. Install dependencies `npm install` +3. Sesuaikan env dengan file .env.example +4. Jalankan local dev derver `npm run start:dev` + +## Techniques + +### Schema Validation + +Lakukan schema validation untuk **data yang masuk dari luar saat runtime (request body, params, dll)**. Tulis validasi di kelas DTO (buat kelas yang pendek boleh langsung pipe di controller). Dokumentasi: + +- [NestJS Validation](https://docs.nestjs.com/techniques/validation) +- [Class Validator](https://www.npmjs.com/package/@nestjs/class-validator/v/0.13.1) + +> **NOTE** <br> Schema validation bersifat whitelist, artinya kalo ga kalian pasang validasinya gak bakal bisa diakses meskipun di runtime kalian tambahin. + +### API Documentation + +Dokumentasi API bisa diakses di [http://localhost:3000/api-docs](http://localhost:3000/api-docs). Yang esensial: +| Decorator | Fungsi | Scope | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `@ApiTags("nama-controller")` | Folder/grup API | Method / Controller | +| `@ApiOperation({ summary: "summary" })` | Description | Method | +| `@ApiResponse({ status: XXX, description: "desc", type: Type })` | Keterangan response API | Method / Controller | +| `@ApiBody({ type: Type })` | Enforce body secara hardcode. Kalo bisa jangan pake ini karena harusnya autogenerate dari `@Body()`. Pake ini kalo kalian pake middleware yang ngepass bodynya ke middleware bukan ke handler | Method | +| `@ApiCookieAuth()` | Auth pake cookie | Method / Controller | +| `@ApiProperty({ example: "example", description: "desc" })` | Register property kelas | Model | +| `@ApiHideProperty()` | Hide property kelas | Model | + +Langkahnya kurang lebih: + +1. Kalo buat tag baru, register tag nya di `src/main.ts` pake `.addTag("tag")` +2. Di kelas yang jadi model transfer object (entity / dto), kasih decorator property +3. Di bagian controller, kasih decorator sesuai kebutuhan auth, response, summary, dll + +Dokumentasi: + +- [NestJS OpenAPI](https://docs.nestjs.com/openapi/introduction) -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +### Environment Variables -## Stay in touch +Kalo nambahin environment variable, kalian harus: -- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +- Tambahin di `env.example` +- Tambahin schema validation. Tulis validasi di `env.validation.ts`. Dokumentasi: [Class Validator](https://www.npmjs.com/package/@nestjs/class-validator/v/0.13.1) -## License +WARNING: -Nest is [MIT licensed](LICENSE). +- Sampe sekarang, `allowUnknown` masih di-set jadi `true`. Artinya kalian bisa aja masukin environment variable tanpa ngelakuin validasi skema. Masalahnya adalah kalo di-set ke `false`, environment variables bawaan lokal kalian kayak `USER`, `NODE_ENV` gitu-gitu jadi ke-restrict. Jadi tolong banget, pake environment variables yang emang udah ke-define aja di validasinya. +- Semua yang diakses pake `process.env.` masih string ya valuenya, dia gak auto transform (meskipun udah pake `class-transformer`). Jadi konversi sesuai kebutuhan masing-masing, ini bener-bener cuman buat validasi skemanya aja. diff --git a/package-lock.json b/package-lock.json index ed3799a3fcf1ec45d7e2d40c205a4b94a1abd5c7..88b4e7cf5b71bf20a1847997a4f1f8261ae0640b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,21 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/axios": "^3.0.2", + "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", + "@nestjs/mapped-types": "*", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.1", "@nestjs/typeorm": "^10.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", + "dayjs": "^1.11.10", + "passport": "^0.7.0", + "passport-custom": "^1.1.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -24,12 +34,15 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/passport": "^1.0.16", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "axios": "^1.6.7", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", @@ -1670,6 +1683,11 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==" + }, "node_modules/@nestjs/axios": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.2.tgz", @@ -1680,6 +1698,15 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/class-validator": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@nestjs/class-validator/-/class-validator-0.13.4.tgz", + "integrity": "sha512-/mqZL36LJ5uV5WDhi87Cd52IssuO+SStaOr2+6sBsvCCGUWkoJes4Wwzmm3m/gdHH+tsNxX60sVSzYcU6hAy9Q==", + "dependencies": { + "libphonenumber-js": "^1.9.43", + "validator": "^13.7.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz", @@ -1888,14 +1915,42 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { - "version": "10.3.3", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.3.tgz", - "integrity": "sha512-GGKSEU48Os7nYFIsUM0nutuFUGn5AbeP8gzFBiBCAtiuJWrXZXpZ58pMBYxAbMf7IrcOZFInHEukjHGAQU0OZw==", + "version": "10.3.7", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.7.tgz", + "integrity": "sha512-noNJ+PyIxQJLCKfuXz0tcQtlVAynfLIuKy62g70lEZ86UrIqSrZFqvWs/rFUgkbT6J8H7Rmv11hASOnX+7M2rA==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", - "express": "4.18.2", + "express": "4.19.2", "multer": "1.4.4-lts.1", "tslib": "2.6.2" }, @@ -1930,6 +1985,38 @@ "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", "dev": true }, + "node_modules/@nestjs/swagger": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.3.1.tgz", + "integrity": "sha512-LUC4mr+5oAleEC/a2j8pNRh1S5xhKXJ1Gal5ZdRjt9XebQgbngXCdW7JTA9WOEcwGtFZN9EnKYdquzH971LZfw==", + "dependencies": { + "@microsoft/tsdoc": "^0.14.2", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.2.0", + "swagger-ui-dist": "5.11.2" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.3.3", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.3.tgz", @@ -2158,6 +2245,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2290,6 +2386,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/passport": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", + "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", @@ -2356,6 +2461,11 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.11.9", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.9.tgz", + "integrity": "sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==" + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -2919,8 +3029,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -2957,7 +3066,6 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", - "peer": true, "dependencies": { "follow-redirects": "^1.15.4", "form-data": "^4.0.0", @@ -3443,6 +3551,21 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3728,9 +3851,29 @@ "dev": true }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", "engines": { "node": ">= 0.6" } @@ -4450,16 +4593,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -4490,29 +4633,6 @@ "node": ">= 0.10.0" } }, - "node_modules/express/node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4531,20 +4651,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, - "node_modules/express/node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -4794,16 +4900,15 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], - "peer": true, "engines": { "node": ">=4.0" }, @@ -6296,7 +6401,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6410,6 +6514,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.58", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.58.tgz", + "integrity": "sha512-53A0IpJFL9LdHbpeatwizf8KSwPICrqn9H0g3Y7WQ+Jgeu9cQ4Ew3WrRtrLBu/CX2lXd5+rgT01/tGlkbkzOjw==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7002,6 +7111,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7071,6 +7216,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", @@ -7384,8 +7534,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "peer": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/punycode": { "version": "2.3.1", @@ -8332,6 +8481,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.11.2.tgz", + "integrity": "sha512-jQG0cRgJNMZ7aCoiFofnoojeSaa/+KgWaDlfgs8QN+BXoGMpxeMVY5OEnjq4OlNvF3yjftO8c9GRAgcHlO+u7A==" + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -9084,6 +9238,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 1f1c5a9790adf7d2f639a8d849efafa899f05127..0d1f6272902f5c1add61fd2b56493b256439aba0 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,21 @@ }, "dependencies": { "@nestjs/axios": "^3.0.2", + "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", + "@nestjs/mapped-types": "*", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.1", "@nestjs/typeorm": "^10.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "cookie-parser": "^1.4.6", + "dayjs": "^1.11.10", + "passport": "^0.7.0", + "passport-custom": "^1.1.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -38,12 +48,15 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/passport": "^1.0.16", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "axios": "^1.6.7", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", diff --git a/src/alokasi-topik/alokasi-topik.controller.ts b/src/alokasi-topik/alokasi-topik.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..069ae9fb2290d3831476d20cb7068beae81fbed4 --- /dev/null +++ b/src/alokasi-topik/alokasi-topik.controller.ts @@ -0,0 +1,174 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiCreatedResponse, + 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 { + CreateBulkTopikDto, + TopikIdRespDto, + CreateTopikDto, + GetAllRespDto, + OmittedTopik, + TopikParamDto, + TopikQueryDto, + UpdateTopikDto, + createBulkRespDto, +} from "./alokasi-topik.dto"; +import { AlokasiTopikService } from "./alokasi-topik.service"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +import { HIGH_AUTHORITY_ROLES, isHighAuthority } from "src/helper/roles"; + +@ApiTags("Alokasi Topik") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("alokasi-topik") +@UseGuards(CustomAuthGuard, RolesGuard) +export class AlokasiTopikController { + constructor(private alokasiTopikService: AlokasiTopikService) {} + + @ApiOperation({ + summary: "Create new topik. Roles: S2_TIM_TESIS, ADMIN, S2_PEMBIMBING", + }) + @ApiCreatedResponse({ type: TopikIdRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, RoleEnum.S2_PEMBIMBING) + @Post() + async create( + @Body() createDto: CreateTopikDto, + @Req() req: Request, + ): Promise<TopikIdRespDto> { + const { roles, id } = req.user as AuthDto; + // user only has S2_PEMBIMBING role + if (!isHighAuthority(roles) && createDto.idPengaju !== id) { + throw new BadRequestException("Pengaju ID harus sama dengan user ID"); + } + + return await this.alokasiTopikService.create(createDto); + } + + @ApiOperation({ + summary: "Create multiple topik. Roles: S2_TIM_TESIS, ADMIN", + }) + @ApiOkResponse({ type: createBulkRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES) + @Post("/bulk") + async createBulk(@Body() createDto: CreateBulkTopikDto) { + return await this.alokasiTopikService.createBulk(createDto); + } + + @ApiOperation({ + summary: + "Get topik by ID. Roles: S2_TIM_TESIS, ADMIN, S2_PEMBIMBING, S2_MAHASISWA", + }) + @ApiOkResponse({ type: OmittedTopik }) + @Roles(...HIGH_AUTHORITY_ROLES, RoleEnum.S2_PEMBIMBING, RoleEnum.S2_MAHASISWA) + @Get("/:id") + async getById(@Param() params: TopikParamDto) { + const res = await this.alokasiTopikService.findActiveTopikById(params.id); + if (!res) throw new NotFoundException(); + return res as OmittedTopik; + } + + @ApiOperation({ + summary: + "Get all topik. Roles: S2_TIM_TESIS, ADMIN, S2_MAHASISWA, S2_PEMBIMBING", + }) + @ApiOkResponse({ type: GetAllRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, RoleEnum.S2_MAHASISWA, RoleEnum.S2_PEMBIMBING) + @Get() + async getAll( + @Query() + query: TopikQueryDto, + ) { + return await this.alokasiTopikService.findAllActiveTopikCreatedByPembimbing( + { + ...query, + page: query.page || 1, + }, + ); + } + + @ApiOperation({ + summary: "Update topik. Roles: S2_TIM_TESIS, ADMIN, S2_PEMBIMBING", + }) + @ApiOkResponse({ type: TopikIdRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, RoleEnum.S2_PEMBIMBING) + @Put("/:id") + async update( + @Param() params: TopikParamDto, + @Body() updateDto: UpdateTopikDto, + @Req() req: Request, + ): Promise<TopikIdRespDto> { + let idPengaju = undefined; + const { roles, id } = req.user as AuthDto; + // user only has S2_PEMBIMBING role + if (!isHighAuthority(roles)) { + if (updateDto.idPengaju !== id) { + throw new BadRequestException("Pengaju ID harus sama dengan user ID"); + } + idPengaju = id; + } + + const res = await this.alokasiTopikService.update( + params.id, + updateDto, + idPengaju, + ); + if (!res.affected) + throw new NotFoundException( + "Topik tidak ditemukan di antara topik yang dapat Anda akses", + ); + + const resp: TopikIdRespDto = { id: params.id }; + + return resp; + } + + @ApiOperation({ + summary: "Delete topik. Roles: S2_TIM_TESIS, ADMIN, S2_PEMBIMBING", + }) + @ApiOkResponse({ type: TopikIdRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, RoleEnum.S2_PEMBIMBING) + @Delete("/:id") + async delete( + @Param() params: TopikParamDto, + @Req() req: Request, + ): Promise<TopikIdRespDto> { + let idPengaju = undefined; + const { roles, id } = req.user as AuthDto; + // user only has S2_PEMBIMBING role + if (!isHighAuthority(roles)) { + idPengaju = id; + } + + const res = await this.alokasiTopikService.remove(params.id, idPengaju); + if (!res.affected) + throw new NotFoundException( + "Topik tidak ditemukan di antara topik yang dapat Anda akses", + ); + + const resp: TopikIdRespDto = { id: params.id }; + return resp; + } +} diff --git a/src/alokasi-topik/alokasi-topik.dto.ts b/src/alokasi-topik/alokasi-topik.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c41225c23ddf1fbefe996dfc7f961ad5ecebc01 --- /dev/null +++ b/src/alokasi-topik/alokasi-topik.dto.ts @@ -0,0 +1,87 @@ +import { + IsNumberString, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "@nestjs/class-validator"; +import { ApiProperty, ApiPropertyOptional, OmitType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { Topik } from "src/entities/topik.entity"; + +export class CreateTopikDto { + @ApiProperty() + @IsString() + judul: string; + + @ApiProperty() + @IsString() + deskripsi: string; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + idPengaju: string; +} + +export class CreateBulkTopikDto { + @ApiProperty({ type: [CreateTopikDto] }) + @ValidateNested({ each: true }) + @Type(() => CreateTopikDto) + data: CreateTopikDto[]; +} + +export class UpdateTopikDto extends CreateTopikDto {} + +export class TopikParamDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + id: string; +} + +export class TopikQueryDto { + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: no limit" }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + search?: string; + + @IsOptional() + @IsUUID() + @ApiPropertyOptional({ example: "550e8400-e29b-41d4-a716-446655440000" }) + idPembimbing?: string; +} + +export class PengajuDto extends OmitType(Pengguna, ["nim", "aktif"] as const) {} + +export class OmittedTopik extends OmitType(Topik, ["idPengaju"] as const) { + @ApiProperty({ type: PengajuDto }) + pengaju: Pengguna; +} + +export class GetAllRespDto { + @ApiProperty({ type: [OmittedTopik] }) + data: OmittedTopik[]; + + @ApiProperty() + maxPage: number; +} + +export class createBulkRespDto { + @ApiProperty() + ids: string[]; +} + +export class TopikIdRespDto { + @ApiProperty() + id: string; +} diff --git a/src/alokasi-topik/alokasi-topik.module.ts b/src/alokasi-topik/alokasi-topik.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7daaff6363cf0e2d4d1bcedeaea2c38023f6a67 --- /dev/null +++ b/src/alokasi-topik/alokasi-topik.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AlokasiTopikService } from "./alokasi-topik.service"; +import { AlokasiTopikController } from "./alokasi-topik.controller"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Topik } from "src/entities/topik.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { AuthModule } from "src/auth/auth.module"; + +@Module({ + imports: [TypeOrmModule.forFeature([Topik]), AuthModule], + providers: [AlokasiTopikService, CustomStrategy], + controllers: [AlokasiTopikController], +}) +export class AlokasiTopikModule {} diff --git a/src/alokasi-topik/alokasi-topik.service.ts b/src/alokasi-topik/alokasi-topik.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d52534adfd7f8155ba67dbf0ffe4684b689e9773 --- /dev/null +++ b/src/alokasi-topik/alokasi-topik.service.ts @@ -0,0 +1,145 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { Topik } from "src/entities/topik.entity"; +import { ArrayContains, ILike, Repository } from "typeorm"; +import { + CreateBulkTopikDto, + TopikIdRespDto, + CreateTopikDto, + GetAllRespDto, + UpdateTopikDto, + createBulkRespDto, +} from "./alokasi-topik.dto"; + +@Injectable() +export class AlokasiTopikService { + constructor(@InjectRepository(Topik) private topikRepo: Repository<Topik>) {} + + async create(createDto: CreateTopikDto): Promise<TopikIdRespDto> { + const ids = (await this.topikRepo.insert(createDto)).identifiers; + + return { id: ids[0].id }; + } + + async createBulk(createDto: CreateBulkTopikDto): Promise<createBulkRespDto> { + const ids = ( + await this.topikRepo.insert(createDto.data.map((dto) => ({ ...dto }))) + ).identifiers; + + return { ids: ids.map(({ id }) => id) }; + } + + async findActiveTopikById(id: string) { + return await this.topikRepo.findOne({ + select: { + id: true, + judul: true, + deskripsi: true, + pengaju: { + id: true, + nama: true, + email: true, + roles: true, + }, + }, + where: { + id, + aktif: true, + }, + relations: { + pengaju: true, + }, + }); + } + + async findAllActiveTopikCreatedByPembimbing(options: { + page: number; + limit?: number; + search?: string; + idPembimbing?: string; + }): Promise<GetAllRespDto> { + const dataQuery = this.topikRepo.find({ + select: { + id: true, + judul: true, + deskripsi: true, + pengaju: { + id: true, + nama: true, + email: true, + roles: true, + }, + }, + where: { + aktif: true, + pengaju: { + id: options.idPembimbing || undefined, + roles: ArrayContains([RoleEnum.S2_PEMBIMBING]), + }, + judul: ILike(`%${options.search || ""}%`), + }, + relations: { + pengaju: true, + }, + order: { + pengaju: { + nama: "ASC", + }, + judul: "ASC", + }, + take: options.limit || undefined, + skip: options.limit ? (options.page - 1) * options.limit : 0, + }); + + if (options.limit) { + let countQuery = this.topikRepo + .createQueryBuilder("topik") + .select("topik.id") + .innerJoinAndSelect("topik.pengaju", "pengaju") + .where("pengaju.roles @> :role", { + role: [RoleEnum.S2_PEMBIMBING], + }); + + if (options.idPembimbing) { + countQuery = countQuery.andWhere("pengaju.id = :id", { + id: options.idPembimbing || undefined, + }); + } + + if (options.search) { + countQuery = countQuery.andWhere("topik.judul LIKE :search", { + search: `%${options.search || ""}%`, + }); + } + + const [count, data] = await Promise.all([ + countQuery.getCount(), + dataQuery, + ]); + + return { + maxPage: Math.ceil(count / options.limit), + data, + }; + } else { + const data = await dataQuery; + return { + maxPage: data.length ? 1 : 0, + data, + }; + } + } + + async update(id: string, updateDto: UpdateTopikDto, idPengaju?: string) { + const findOpt = idPengaju + ? { id, idPengaju, aktif: true } + : { id, aktif: true }; + return await this.topikRepo.update(findOpt, updateDto); + } + + async remove(id: string, idPengaju?: string) { + const findOpt = idPengaju ? { id, idPengaju } : { id }; + return await this.topikRepo.update(findOpt, { aktif: false }); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 171261220ecfc70b89c378e1e99dfedd349b039c..25728d3124a279326173b50c046e2cb22dc0ac8a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,32 +1,33 @@ import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; -import { TypeOrmModule } from "@nestjs/typeorm"; import { Bimbingan } from "./entities/bimbingan.entity"; import { Pengguna } from "./entities/pengguna.entity"; -import { 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 { PengajarKelas } from "./entities/pengajarKelas.entity"; -import { PengajuanPengambilanTopik } from "./entities/pengajuanPengambilanTopik.entity"; -import { PengambilanTopik } from "./entities/pengambilanTopik.entity"; -import { RangeJadwalSidang } from "./entities/rangeJadwalSidang.entity"; -import { Ruangan } from "./entities/ruangan.entity"; -import { Sidang } from "./entities/sidang.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 { PendaftaranTesis } from "./entities/pendaftaranTesis.entity"; +import { PengujiSidsem } from "./entities/pengujiSidsem.entity"; import { RegistrasiTesisModule } from "./registrasi-tesis/registrasi-tesis.module"; import { ConfigModule } from "@nestjs/config"; +import { RegistrasiSidsemModule } from "./registrasi-sidsem/registrasi-sidsem.module"; +import { AlokasiTopikModule } from "./alokasi-topik/alokasi-topik.module"; +import { AuthModule } from "./auth/auth.module"; +import { BimbinganModule } from "./bimbingan/bimbingan.module"; +import { validate } from "./env.validation"; +import { BerkasBimbingan } from "./entities/berkasBimbingan.entity"; +import { PendaftaranSidsem } from "./entities/pendaftaranSidsem"; +import { DosenBimbinganModule } from "./dosen-bimbingan/dosen-bimbingan.module"; +import { PenggunaModule } from "./pengguna/pengguna.module"; +import { KonfigurasiModule } from "./konfigurasi/konfigurasi.module"; +import { Konfigurasi } from "./entities/konfigurasi.entity"; +import { DashboardModule } from "./dashboard/dashboard.module"; +import { BerkasSidsem } from "./entities/berkasSidsem.entity"; +import { DosenPengujiModule } from "./dosen-penguji/dosen-penguji.module"; @Module({ imports: [ - ConfigModule.forRoot(), + ConfigModule.forRoot({ validate }), TypeOrmModule.forRoot({ type: "postgres", host: process.env.POSTGRES_HOST, @@ -36,30 +37,29 @@ import { ConfigModule } from "@nestjs/config"; database: process.env.POSTGRES_DATABASE, ssl: process.env.POSTGRES_HOST !== "localhost", entities: [ + BerkasBimbingan, Bimbingan, Pengguna, - RangeJadwalSeminar, - Seminar, + PendaftaranSidsem, Topik, - AuditLog, DosenBimbingan, - Kelas, - MahasiswaKelas, - PengajarKelas, - PengajuanPengambilanTopik, - PengambilanTopik, - RangeJadwalSidang, - Ruangan, - Sidang, - Tugas, - PembimbingSeminar, - PembimbingSidang, - PengujiSidang, + Konfigurasi, + PendaftaranTesis, + PengujiSidsem, + BerkasSidsem, ], - // autoLoadEntities: true, synchronize: true, }), RegistrasiTesisModule, + AuthModule, + AlokasiTopikModule, + DashboardModule, + BimbinganModule, + DosenBimbinganModule, + RegistrasiSidsemModule, + PenggunaModule, + KonfigurasiModule, + DosenPengujiModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/auth.dto.ts b/src/auth/auth.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd100449dd14ec85ce026bc5be22872248cef3c6 --- /dev/null +++ b/src/auth/auth.dto.ts @@ -0,0 +1,8 @@ +import { RoleEnum } from "src/entities/pengguna.entity"; + +export class AuthDto { + id: string; + nama: string; + email: string; + roles: RoleEnum[]; +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..0691876b3e4130efa099cda32455ba2a4797e794 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { AuthService } from "./auth.service"; +import { HttpModule } from "@nestjs/axios"; + +@Module({ + imports: [HttpModule], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca7049c75be0ca18491406211616531da0fd65a4 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,28 @@ +import { HttpService } from "@nestjs/axios"; +import { HttpException, Injectable } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { catchError, firstValueFrom } from "rxjs"; +import { AuthDto } from "src/auth/auth.dto"; + +@Injectable() +export class AuthService { + constructor(private httpService: HttpService) {} + + async validate(token: string) { + const user = await firstValueFrom( + this.httpService + .get(`${process.env.AUTH_SERVICE_URL}/auth/self`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .pipe( + catchError((error: AxiosError) => { + throw new HttpException(error.response.data, error.response.status); + }), + ), + ); + + return user.data as AuthDto; + } +} diff --git a/src/bimbingan/bimbingan.controller.ts b/src/bimbingan/bimbingan.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f03874b94e456fe8a890c47cc05661fb890835d --- /dev/null +++ b/src/bimbingan/bimbingan.controller.ts @@ -0,0 +1,118 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiBody, + ApiCookieAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +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 { + ByMhsIdDto, + CreateBimbinganReqDto, + CreateBimbinganResDto, + GetByBimbinganIdResDto, + GetByMahasiswaIdResDto, + UpdateStatusDto, + UpdateStatusResDto, +} from "./bimbingan.dto"; +import { BimbinganService } from "./bimbingan.service"; + +@ApiTags("Bimbingan") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("bimbingan") +@UseGuards(CustomAuthGuard, RolesGuard) +export class BimbinganController { + constructor(private readonly bimbinganService: BimbinganService) {} + + @ApiOkResponse({ type: GetByMahasiswaIdResDto }) + @ApiNotFoundResponse({ description: "Tidak ada pendaftaran" }) + @Roles(RoleEnum.S2_PEMBIMBING, RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) + @Get("/mahasiswa/:mahasiswaId") + async getByMahasiswaId( + @Param() param: ByMhsIdDto, + @Req() request: Request, + ): Promise<GetByMahasiswaIdResDto> { + return (await this.bimbinganService.getByMahasiswaId( + param.mahasiswaId, + request.user as AuthDto, + )) as GetByMahasiswaIdResDto; + } + + @ApiOkResponse({ type: GetByMahasiswaIdResDto }) + @ApiNotFoundResponse({ description: "Tidak ada pendaftaran" }) + @Roles(RoleEnum.S2_MAHASISWA) + @Get("/") + async getOwnBimbingan( + @Req() request: Request, + ): Promise<GetByMahasiswaIdResDto> { + return this.bimbinganService.getByMahasiswaId( + (request.user as AuthDto).id, + request.user as AuthDto, + ); + } + + @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" }) + @Roles(RoleEnum.S2_MAHASISWA) + @ApiBody({ type: CreateBimbinganReqDto }) + @Post("/") + 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 new file mode 100644 index 0000000000000000000000000000000000000000..649f9c95366f56738a281689e0520c876ec8cef7 --- /dev/null +++ b/src/bimbingan/bimbingan.dto.ts @@ -0,0 +1,108 @@ +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"; + +class MhsRes extends PickType(Pengguna, ["id", "nama", "email"] as const) { + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; +} + +class PickedTopikBimbingan extends PickType(Topik, [ + "id", + "judul", + "deskripsi", + "idPengaju", +] as const) {} + +export class GetByMahasiswaIdResDto { + @ApiProperty({ type: [Bimbingan] }) + bimbingan: Bimbingan[]; + + @ApiProperty({ type: MhsRes }) + mahasiswa: MhsRes; + + @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() + waktuBimbingan: string; + + @ApiProperty() + @IsString() + laporanKemajuan: string; + + @ApiProperty() + @IsString() + todo: string; + + @ApiProperty({ type: Date }) + @IsDateString() + @IsOptional() + bimbinganBerikutnya: string; + + @ApiProperty({ type: [BerkasWithoutId] }) + @ValidateNested({ each: true }) + @Type(() => BerkasWithoutId) + @IsDefined() + berkas: BerkasWithoutId[]; +} + +export class CreateBimbinganResDto { + @ApiProperty() + 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 new file mode 100644 index 0000000000000000000000000000000000000000..b77070fda6099172ac83112b4163af9a742f656d --- /dev/null +++ b/src/bimbingan/bimbingan.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { BimbinganController } from "./bimbingan.controller"; +import { BimbinganService } from "./bimbingan.service"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Bimbingan } from "src/entities/bimbingan.entity"; +import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; +import { PenggunaModule } from "src/pengguna/pengguna.module"; +import { PenggunaService } from "src/pengguna/pengguna.service"; +import { Pengguna } from "src/entities/pengguna.entity"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Bimbingan, + PendaftaranTesis, + DosenBimbingan, + BerkasBimbingan, + Pengguna, + ]), + PenggunaModule, + ], + controllers: [BimbinganController], + providers: [BimbinganService, PenggunaService], + exports: [BimbinganService], +}) +export class BimbinganModule {} diff --git a/src/bimbingan/bimbingan.service.ts b/src/bimbingan/bimbingan.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..825ce9109b5ac2c4be8f5e68aa0e600b2eda67e0 --- /dev/null +++ b/src/bimbingan/bimbingan.service.ts @@ -0,0 +1,254 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import * as dayjs from "dayjs"; +import { AuthDto } from "src/auth/auth.dto"; +import { Bimbingan, BimbinganStatus } from "src/entities/bimbingan.entity"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { + PendaftaranTesis, + RegStatus, +} from "src/entities/pendaftaranTesis.entity"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { Repository } from "typeorm"; +import { + CreateBimbinganReqDto, + CreateBimbinganResDto, + GetByBimbinganIdResDto, + GetByMahasiswaIdResDto, + UpdateStatusDto, + UpdateStatusResDto, +} from "./bimbingan.dto"; +import { BerkasBimbingan } from "src/entities/berkasBimbingan.entity"; +import { PenggunaService } from "src/pengguna/pengguna.service"; + +@Injectable() +export class BimbinganService { + constructor( + @InjectRepository(Bimbingan) + private bimbinganRepository: Repository<Bimbingan>, + @InjectRepository(PendaftaranTesis) + private pendaftaranTesisRepository: Repository<PendaftaranTesis>, + @InjectRepository(DosenBimbingan) + private dosenBimbinganRepository: Repository<DosenBimbingan>, + @InjectRepository(BerkasBimbingan) + private berkasBimbinganRepository: Repository<BerkasBimbingan>, + private penggunaService: PenggunaService, + ) {} + + async getByMahasiswaId( + mahasiswaId: string, + user: AuthDto, + ): Promise<GetByMahasiswaIdResDto> { + await this.penggunaService.isMahasiswaAktifOrFail(mahasiswaId); + + const pendaftaran = await this.pendaftaranTesisRepository.findOne({ + where: { + mahasiswa: { id: mahasiswaId }, + status: RegStatus.APPROVED, + }, + relations: { + mahasiswa: true, + topik: true, + penerima: true, + }, + }); + + if (!pendaftaran) { + throw new NotFoundException("Tidak ada pendaftaran yang disetujui"); + } + + // Validate bimbingan data by its dosbim + // only validate if user isn't high authority + // and user is not mhs checking their own logs + if ( + !user.roles.includes(RoleEnum.ADMIN) && + !user.roles.includes(RoleEnum.S2_TIM_TESIS) && + !(user.roles.includes(RoleEnum.S2_MAHASISWA) && mahasiswaId == user.id) + ) { + const dosbim = await this.dosenBimbinganRepository.find({ + where: { pendaftaran: { id: pendaftaran.id } }, + relations: { dosen: true }, + }); + + if (!dosbim.map((d) => d.dosen.id).includes(user.id)) { + throw new ForbiddenException(); + } + } + + const bimbingan = await this.bimbinganRepository.find({ + where: { + pendaftaran: { + id: pendaftaran.id, + }, + }, + relations: { + berkas: true, + }, + }); + + const status = await this.getBimbinganStatus(pendaftaran); + + return { + bimbingan, + mahasiswa: { + id: pendaftaran.mahasiswa.id, + nama: pendaftaran.mahasiswa.nama, + email: pendaftaran.mahasiswa.email, + jalurPilihan: pendaftaran.jalurPilihan, + }, + topik: pendaftaran.topik, + status, + }; + } + + async create( + mahasiswaId: string, + createDto: CreateBimbinganReqDto, + ): Promise<CreateBimbinganResDto> { + // Check if user registered in bimbingan + const pendaftaran = await this.pendaftaranTesisRepository.findOne({ + where: { + mahasiswa: { id: mahasiswaId }, + status: RegStatus.APPROVED, + }, + relations: { + mahasiswa: true, + topik: true, + penerima: true, + }, + }); + + if (!pendaftaran) { + throw new NotFoundException("Tidak ada pendaftaran yang disetujui"); + } + + if (dayjs(createDto.waktuBimbingan).isAfter(dayjs(new Date()).endOf("d"))) + throw new BadRequestException( + "Tanggal bimbingan yang dimasukkan tidak boleh melebihi tanggal hari ini", + ); + + if ( + dayjs(createDto.bimbinganBerikutnya) + .endOf("D") + .isBefore(dayjs(createDto.waktuBimbingan).startOf("D")) + ) + throw new BadRequestException( + "Bimbingan berikutnya harus setelah bimbingan yang dimasukkan", + ); + + const berkasBimbingan = createDto.berkas.map((berkas) => + this.berkasBimbinganRepository.create(berkas), + ); + + const createdBimbinganLog = this.bimbinganRepository.create({ + waktuBimbingan: createDto.waktuBimbingan, + laporanKemajuan: createDto.laporanKemajuan, + todo: createDto.todo, + bimbinganBerikutnya: createDto.bimbinganBerikutnya, + berkas: berkasBimbingan, + pendaftaran, + }); + + await this.bimbinganRepository.save(createdBimbinganLog); + + return { id: createdBimbinganLog.id }; + } + + async updateStatus( + user: AuthDto, + dto: UpdateStatusDto, + ): Promise<UpdateStatusResDto> { + const bimbingan = await this.getByBimbinganId(user, dto.bimbinganId); // already check if mahasiswa is aktif + + await this.bimbinganRepository.update(bimbingan.id, { + disahkan: dto.status, + }); + + return { + id: bimbingan.id, + }; + } + + async getByBimbinganId( + user: AuthDto, + bimbinganId: string, + ): Promise<GetByBimbinganIdResDto> { + 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") + .leftJoinAndSelect("pendaftaran.mahasiswa", "mahasiswa") + .where("bimbingan.id = :id", { id: bimbinganId }); + const bimbingan = await bimbinganQuery.getOne(); + + if (!bimbingan) { + throw new NotFoundException("Bimbingan tidak ditemukan"); + } + + if (!bimbingan.pendaftaran.mahasiswa.aktif) { + throw new BadRequestException("Bimbingan milik mahasiswa tidak aktif"); + } + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..8cec5c50418c02eac5f35279a28b7582647e149e --- /dev/null +++ b/src/dashboard/dashboard.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Get, Query, Req, UseGuards } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiOkResponse, + ApiTags, +} from "@nestjs/swagger"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +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 { + DashboardDto, + GetDashboardDosbimQueryDto, + GetDashboardTimTesisReqQueryDto, + GetDashboardTimTesisRespDto, + JalurStatisticDto, +} from "./dashboard.dto"; +import { DashboardService } from "./dashboard.service"; + +@ApiTags("Dashboard") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("dashboard") +@UseGuards(CustomAuthGuard, RolesGuard) +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @ApiOkResponse({ type: [DashboardDto] }) + @Roles(RoleEnum.S2_PEMBIMBING) + @Get("/dosbim") + async findByPenerimaId( + @Req() request: Request, + @Query() query: GetDashboardDosbimQueryDto, + ): Promise<DashboardDto[]> { + return this.dashboardService.findByDosenId( + (request.user as AuthDto).id, + query.search, + ); + } + + @ApiOkResponse({ type: [JalurStatisticDto] }) + @Roles(RoleEnum.S2_PEMBIMBING) + @Get("/dosbim/statistics") + async getStatisticsByJalurPilihan(@Req() request: Request) { + return this.dashboardService.getStatisticsByJalurPilihan( + (request.user as AuthDto).id, + ); + } + + @UseGuards(CustomAuthGuard, RolesGuard) + @Roles(RoleEnum.S2_TIM_TESIS, RoleEnum.ADMIN) + @ApiOkResponse({ type: GetDashboardTimTesisRespDto }) + @Get("/tim-tesis") + async getDashboardTimTesis( + @Query() query: GetDashboardTimTesisReqQueryDto, + ): Promise<GetDashboardTimTesisRespDto> { + return this.dashboardService.getDashboardTimTesis(query); + } +} diff --git a/src/dashboard/dashboard.dto.ts b/src/dashboard/dashboard.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..adfb93e0e0a210e077c1704e260e90c164e15e7e --- /dev/null +++ b/src/dashboard/dashboard.dto.ts @@ -0,0 +1,103 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { IsNumberString, IsOptional, IsString } from "class-validator"; +import { BimbinganStatus } from "src/entities/bimbingan.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { Topik } from "src/entities/topik.entity"; +import { JalurEnum } from "../entities/pendaftaranTesis.entity"; + +class PickedTopikDashboard extends PickType(Topik, ["id", "judul"] as const) {} +class PickedMhsDashboard extends PickType(Pengguna, [ + "id", + "nama", + "nim", + "email", +] as const) {} + +class NoEmailUserDashboard extends OmitType(PickedMhsDashboard, [ + "email", +] as const) {} + +export class DashboardDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + id: string; + + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; + + @ApiProperty({ enum: BimbinganStatus }) + status: BimbinganStatus; + + @ApiProperty() + topik: PickedTopikDashboard; + + @ApiProperty() + mahasiswa: NoEmailUserDashboard; +} + +export class JalurStatisticDto { + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; + + @ApiProperty() + count: number; +} + +export class GetDashboardDosbimQueryDto { + @ApiPropertyOptional({}) + @IsOptional() + search: string; +} + +export class GetDashboardTimTesisReqQueryDto { + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: no limit" }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + search?: string; +} + +export enum DashboardTimTesisStatusEnum { + PENGAJUAN_TOPIK = "PENGAJUAN_TOPIK", + SEMINAR_1 = "SEMINAR_1", + SEMINAR_2 = "SEMINAR_2", + SIDANG = "SIDANG", +} + +class GetDashboardTimTesisDataDto { + @ApiProperty() + id_mahasiswa: string; + + @ApiProperty() + nim_mahasiswa: string; + + @ApiProperty() + nama_mahasiswa: string; + + @ApiProperty({ isArray: true }) + dosen_pembimbing: string[]; + + @ApiProperty({ isArray: true, enum: DashboardTimTesisStatusEnum }) + status: DashboardTimTesisStatusEnum[]; +} + +export class GetDashboardTimTesisRespDto { + @ApiProperty() + maxPage: number; + + @ApiProperty({ type: GetDashboardTimTesisDataDto }) + data: GetDashboardTimTesisDataDto[]; +} diff --git a/src/dashboard/dashboard.module.ts b/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad95d2fc648e9ebf0247fb7f326f682b9a1c8cf3 --- /dev/null +++ b/src/dashboard/dashboard.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { BimbinganModule } from "src/bimbingan/bimbingan.module"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { PendaftaranSidsem } from "src/entities/pendaftaranSidsem"; +import { PendaftaranTesis } from "../entities/pendaftaranTesis.entity"; +import { Pengguna } from "../entities/pengguna.entity"; +import { DashboardController } from "./dashboard.controller"; +import { DashboardService } from "./dashboard.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + PendaftaranTesis, + Pengguna, + PendaftaranSidsem, + DosenBimbingan, + ]), + BimbinganModule, + ], + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/src/dashboard/dashboard.service.ts b/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..29e867e2227ed68513532f4fe109daf7c30fe550 --- /dev/null +++ b/src/dashboard/dashboard.service.ts @@ -0,0 +1,303 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BimbinganService } from "src/bimbingan/bimbingan.service"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { + PendaftaranSidsem, + SidsemStatus, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { ArrayContains, Brackets, In, Like, Repository } from "typeorm"; +import { + PendaftaranTesis, + RegStatus, +} from "../entities/pendaftaranTesis.entity"; +import { Pengguna, RoleEnum } from "../entities/pengguna.entity"; +import { + DashboardDto, + DashboardTimTesisStatusEnum, + GetDashboardTimTesisReqQueryDto, + GetDashboardTimTesisRespDto, + JalurStatisticDto, +} from "./dashboard.dto"; + +@Injectable() +export class DashboardService { + constructor( + @InjectRepository(PendaftaranTesis) + private pendaftaranTesisRepository: Repository<PendaftaranTesis>, + @InjectRepository(Pengguna) + private penggunaRepository: Repository<Pengguna>, + @InjectRepository(DosenBimbingan) + private dosenBimbinganRepository: Repository<DosenBimbingan>, + @InjectRepository(PendaftaranSidsem) + private pendaftaranSidsemRepository: Repository<PendaftaranSidsem>, + private bimbinganService: BimbinganService, + ) {} + + async findAll(): Promise<PendaftaranTesis[]> { + return this.pendaftaranTesisRepository.find({ + relations: ["mahasiswa", "topik"], + }); + } + + async findByDosenId( + dosenId: string, + search?: string, + ): Promise<DashboardDto[]> { + let pendaftaranTesisQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pendaftaranTesis") + .leftJoinAndSelect("pendaftaranTesis.mahasiswa", "mahasiswa") + .leftJoinAndSelect("pendaftaranTesis.topik", "topik") + .innerJoin( + "pendaftaranTesis.dosenBimbingan", + "dosenBimbingan", + "dosenBimbingan.idDosen = :dosenId", + { + dosenId, + }, + ) + .where("mahasiswa.aktif = true") + .andWhere("pendaftaranTesis.status = :status", { + status: RegStatus.APPROVED, + }); + + 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: { + id: pendaftaran.topik.id, + judul: pendaftaran.topik.judul, + }, + mahasiswa: { + id: pendaftaran.mahasiswa.id, + nama: pendaftaran.mahasiswa.nama, + nim: pendaftaran.mahasiswa.nim, + }, + }; + }); + } + + async getStatisticsByJalurPilihan( + dosenId: string, + ): Promise<JalurStatisticDto[]> { + const dosen = await this.penggunaRepository.findOne({ + where: { id: dosenId }, + }); + if (!dosen) { + throw new BadRequestException("Dosen tidak ditemukan"); + } + + const statistics = await this.pendaftaranTesisRepository + .createQueryBuilder("pendaftaranTesis") + .select("pendaftaranTesis.jalurPilihan", "jalurPilihan") + .addSelect("COUNT(*)", "count") + .leftJoin("pendaftaranTesis.mahasiswa", "mahasiswa") + .innerJoin( + "pendaftaranTesis.dosenBimbingan", + "dosenBimbingan", + "dosenBimbingan.idDosen = :dosenId", + { + dosenId, + }, + ) + .where("mahasiswa.aktif = true") + .andWhere("pendaftaranTesis.status = :status", { + status: RegStatus.APPROVED, + }) + .groupBy("pendaftaranTesis.jalurPilihan") + .getRawMany(); + + return statistics as JalurStatisticDto[]; + } + + async getDashboardTimTesis( + query: GetDashboardTimTesisReqQueryDto, + ): Promise<GetDashboardTimTesisRespDto> { + const [foundMahasiswa, total] = await this.penggunaRepository.findAndCount({ + select: { + id: true, + nama: true, + nim: true, + }, + take: query.limit || undefined, + skip: (query.page - 1) * query.limit || undefined, + where: [ + { + nim: Like(`%${query.search ?? ""}%`), + roles: ArrayContains([RoleEnum.S2_MAHASISWA]), + aktif: true, + }, + { + nama: Like(`%${query.search ?? ""}%`), + roles: ArrayContains([RoleEnum.S2_MAHASISWA]), + aktif: true, + }, + ], + order: { + nim: "ASC", + }, + }); + + const dosbimQuery = this.dosenBimbinganRepository.find({ + select: { + pendaftaran: { + id: true, + mahasiswaId: true, + }, + dosen: { + id: true, + nama: true, + }, + }, + relations: { + pendaftaran: true, + dosen: true, + }, + where: { + pendaftaran: { + mahasiswaId: In(foundMahasiswa.map(({ id }) => id)), + }, + }, + }); + + const topicAcceptedQuery = this.pendaftaranTesisRepository.find({ + select: { + id: true, + mahasiswaId: true, + }, + where: { + status: RegStatus.APPROVED, + mahasiswaId: In(foundMahasiswa.map(({ id }) => id)), + }, + }); + + const mhsSemProAcceptedQuery = this.pendaftaranSidsemRepository.find({ + select: { + pendaftaranTesis: { + mahasiswaId: true, + }, + }, + relations: { + pendaftaranTesis: true, + }, + where: { + tipe: TipeSidsemEnum.SEMINAR_1, + status: SidsemStatus.APPROVED, + pendaftaranTesis: { + mahasiswaId: In(foundMahasiswa.map(({ id }) => id)), + }, + }, + }); + + const mhsSemTesAcceptedQuery = this.pendaftaranSidsemRepository.find({ + select: { + pendaftaranTesis: { + mahasiswaId: true, + }, + }, + relations: { + pendaftaranTesis: true, + }, + where: { + tipe: TipeSidsemEnum.SEMINAR_2, + status: SidsemStatus.APPROVED, + pendaftaranTesis: { + mahasiswaId: In(foundMahasiswa.map(({ id }) => id)), + }, + }, + }); + + const mhsSidangAcceptedQuery = this.pendaftaranSidsemRepository.find({ + select: { + pendaftaranTesis: { + mahasiswaId: true, + }, + }, + relations: { + pendaftaranTesis: true, + }, + where: { + tipe: TipeSidsemEnum.SIDANG, + status: SidsemStatus.APPROVED, + pendaftaranTesis: { + mahasiswaId: In(foundMahasiswa.map(({ id }) => id)), + }, + }, + }); + + const [ + foundDosbim, + topicAccepted, + mhsSemProAccepted, + mhsSemTesAccepted, + mhsSidangAccepted, + ] = await Promise.all([ + dosbimQuery, + topicAcceptedQuery, + mhsSemProAcceptedQuery, + mhsSemTesAcceptedQuery, + mhsSidangAcceptedQuery, + ]); + + const mhsStatusMap: Record<string, DashboardTimTesisStatusEnum[]> = {}; + const dosbimMap: Record<string, string[]> = {}; + + foundMahasiswa.forEach(({ id }) => { + mhsStatusMap[id] = []; + dosbimMap[id] = []; + }); + + foundDosbim.forEach(({ pendaftaran: { mahasiswaId }, dosen: { nama } }) => { + dosbimMap[mahasiswaId].push(nama); + }); + + topicAccepted.forEach(({ mahasiswaId }) => { + mhsStatusMap[mahasiswaId].push( + DashboardTimTesisStatusEnum.PENGAJUAN_TOPIK, + ); + }); + + mhsSemProAccepted.forEach(({ pendaftaranTesis: { mahasiswaId } }) => { + mhsStatusMap[mahasiswaId].push(DashboardTimTesisStatusEnum.SEMINAR_1); + }); + + mhsSemTesAccepted.forEach(({ pendaftaranTesis: { mahasiswaId } }) => { + mhsStatusMap[mahasiswaId].push(DashboardTimTesisStatusEnum.SEMINAR_2); + }); + + mhsSidangAccepted.forEach(({ pendaftaranTesis: { mahasiswaId } }) => { + mhsStatusMap[mahasiswaId].push(DashboardTimTesisStatusEnum.SIDANG); + }); + + return { + maxPage: !!query.limit ? Math.ceil(total / query.limit) : 1, + data: foundMahasiswa.map(({ nim, id, nama }) => ({ + id_mahasiswa: id, + nim_mahasiswa: nim, + nama_mahasiswa: nama, + status: mhsStatusMap[id] ?? [], + dosen_pembimbing: dosbimMap[id] ?? [], + })), + }; + } +} diff --git a/src/dosen-bimbingan/dosen-bimbingan.controller.ts b/src/dosen-bimbingan/dosen-bimbingan.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..81a31679c5443f03c6469b478be79566aa2e80b0 --- /dev/null +++ b/src/dosen-bimbingan/dosen-bimbingan.controller.ts @@ -0,0 +1,34 @@ +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 { GetDosbimResDto } from "./dosen-bimbingan.dto"; +import { DosenBimbinganService } from "./dosen-bimbingan.service"; + +@ApiTags("Dosen Bimbingan") +@ApiCookieAuth() +@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] }) + @ApiOperation({ + summary: + "Get all available dosen bimbingan. Roles: ADMIN, S2_TIM_TESIS, S2_MAHASISWA", + }) + @Get() + 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 new file mode 100644 index 0000000000000000000000000000000000000000..47a532f830f4d2f30afeff37c5e57b5a6a3e4b82 --- /dev/null +++ b/src/dosen-bimbingan/dosen-bimbingan.dto.ts @@ -0,0 +1,9 @@ +import { PickType } from "@nestjs/swagger"; +import { Pengguna } from "src/entities/pengguna.entity"; + +export class GetDosbimResDto extends PickType(Pengguna, [ + "id", + "email", + "nama", + "keahlian", +] as const) {} diff --git a/src/dosen-bimbingan/dosen-bimbingan.module.ts b/src/dosen-bimbingan/dosen-bimbingan.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1721d9d65412532695c2c187b62f6aec9e248070 --- /dev/null +++ b/src/dosen-bimbingan/dosen-bimbingan.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { DosenBimbinganController } from "./dosen-bimbingan.controller"; +import { DosenBimbinganService } from "./dosen-bimbingan.service"; +import { AuthModule } from "src/auth/auth.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; + +@Module({ + imports: [TypeOrmModule.forFeature([Pengguna]), AuthModule], + controllers: [DosenBimbinganController], + providers: [DosenBimbinganService, CustomStrategy], +}) +export class DosenBimbinganModule {} diff --git a/src/dosen-bimbingan/dosen-bimbingan.service.ts b/src/dosen-bimbingan/dosen-bimbingan.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d9039a1dbc62780d044cb2fac6f09239e2ebc07 --- /dev/null +++ b/src/dosen-bimbingan/dosen-bimbingan.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { ArrayContains, Repository } from "typeorm"; + +@Injectable() +export class DosenBimbinganService { + constructor( + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + ) {} + + async getAll() { + return await this.penggunaRepo.find({ + select: { + id: true, + nama: true, + email: true, + keahlian: true, + }, + where: { + roles: ArrayContains([RoleEnum.S2_PEMBIMBING]), + }, + }); + } +} diff --git a/src/dosen-penguji/dosen-penguji.controller.ts b/src/dosen-penguji/dosen-penguji.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..d50cb97c330b731985268617b2e50fe6087c7066 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { GetDosujiResDto } from "./dosen-penguji.dto"; +import { DosenPengujiService } from "./dosen-penguji.service"; + +@ApiTags("Dosen Penguji") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("dosen-penguji") +@UseGuards(CustomAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) +export class DosenPengujiController { + constructor(private readonly dosujiService: DosenPengujiService) {} + + @ApiOkResponse({ type: [GetDosujiResDto] }) + @ApiOperation({ + summary: "Get all available dosen penguji. Roles: ADMIN, S2_TIM_TESIS", + }) + @Get() + async get() { + return await this.dosujiService.getAll(); + } +} diff --git a/src/dosen-penguji/dosen-penguji.dto.ts b/src/dosen-penguji/dosen-penguji.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..99edd73b7e11ddf929d78756346cdf4efd0913fb --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.dto.ts @@ -0,0 +1,9 @@ +import { PickType } from "@nestjs/swagger"; +import { Pengguna } from "src/entities/pengguna.entity"; + +export class GetDosujiResDto extends PickType(Pengguna, [ + "id", + "email", + "nama", + "keahlian", +] as const) {} diff --git a/src/dosen-penguji/dosen-penguji.module.ts b/src/dosen-penguji/dosen-penguji.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c64d3a567c20ecd9f1e949e6f99d7e998db7d13 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "src/auth/auth.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { DosenPengujiController } from "./dosen-penguji.controller"; +import { DosenPengujiService } from "./dosen-penguji.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([Pengguna]), AuthModule], + controllers: [DosenPengujiController], + providers: [DosenPengujiService, CustomStrategy], +}) +export class DosenPengujiModule {} diff --git a/src/dosen-penguji/dosen-penguji.service.ts b/src/dosen-penguji/dosen-penguji.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..712b5ced205297b509b4d23820557969a7f51888 --- /dev/null +++ b/src/dosen-penguji/dosen-penguji.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { ArrayContains, Repository } from "typeorm"; + +@Injectable() +export class DosenPengujiService { + constructor( + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + ) {} + + async getAll() { + return await this.penggunaRepo.find({ + select: { + id: true, + nama: true, + email: true, + keahlian: true, + }, + where: { + roles: ArrayContains([RoleEnum.S2_PENGUJI]), + }, + }); + } +} diff --git a/src/dto/registrasi-topik.ts b/src/dto/registrasi-topik.ts deleted file mode 100644 index 3d0021750739795336deb7d08c75ee7d14f36141..0000000000000000000000000000000000000000 --- a/src/dto/registrasi-topik.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class RegistrasiTopikDto { - idMahasiswa: string; - idPembimbing: string; - judulTopik: string; - deskripsi: string; - jalurPilihan: string; -} diff --git a/src/entities/auditLog.entity.ts b/src/entities/auditLog.entity.ts deleted file mode 100644 index c95d05a03fb11fb13d835bc1e6c64b22e107bff6..0000000000000000000000000000000000000000 --- a/src/entities/auditLog.entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class AuditLog { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - pengguna: Pengguna; -} 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/berkasSidsem.entity.ts b/src/entities/berkasSidsem.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..7105028e2c852feb7127e4d7837236f2752ec58d --- /dev/null +++ b/src/entities/berkasSidsem.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { IsString, IsUrl } from "@nestjs/class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { PendaftaranSidsem } from "./pendaftaranSidsem"; + +@Entity() +export class BerkasSidsem { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne( + () => PendaftaranSidsem, + (pendaftaranSidsem) => pendaftaranSidsem.id, + { + orphanedRowAction: "delete", + }, + ) + pendaftaranSidsem: PendaftaranSidsem; + + @Column({ type: "text" }) + @IsString() + @ApiProperty() + nama: string; + + @Column({ type: "text" }) + @IsUrl() + @ApiProperty({ example: "https://example.com/berkas.pdf" }) + url: string; +} diff --git a/src/entities/bimbingan.entity.ts b/src/entities/bimbingan.entity.ts index 997e08193353a428c388ee21ee96a9c4ca146020..a846e824d3d98389e6a2402efad351f49188f9fb 100644 --- a/src/entities/bimbingan.entity.ts +++ b/src/entities/bimbingan.entity.ts @@ -1,15 +1,56 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Topik } from "./topik.entity"; -import { Pengguna } from "./pengguna.entity"; +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 { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) @PrimaryGeneratedColumn("uuid") id: string; - @ManyToOne(() => Topik, (topik) => topik.id) - topik: Topik; + @ApiProperty({ type: Date }) + @Column({ type: "date" }) + waktuBimbingan: string; + + @ApiProperty() + @Column({ type: "text" }) + laporanKemajuan: string; + + @ApiProperty() + @Column({ type: "text" }) + todo: string; + + @ApiPropertyOptional({ type: Date }) + @Column({ type: "date", nullable: true }) + bimbinganBerikutnya: string; + + @ApiProperty() + @Column({ type: "boolean", default: true }) + disahkan: boolean; + + @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) + pendaftaran: PendaftaranTesis; - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; + @ApiProperty({ type: [BerkasBimbingan] }) + @OneToMany( + () => BerkasBimbingan, + (berkasBimbingan) => berkasBimbingan.bimbingan, + { + cascade: true, + }, + ) + berkas: BerkasBimbingan[]; } diff --git a/src/entities/dosenBimbingan.entity.ts b/src/entities/dosenBimbingan.entity.ts index 50450bf3ad46f912b6b387944240e0d15a93c318..a43bc7bd6b7dd7e5d69b7bf9f98803707c66dd34 100644 --- a/src/entities/dosenBimbingan.entity.ts +++ b/src/entities/dosenBimbingan.entity.ts @@ -1,15 +1,29 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Bimbingan } from "./bimbingan.entity"; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { Pengguna } from "./pengguna.entity"; +import { PendaftaranTesis } from "./pendaftaranTesis.entity"; @Entity() export class DosenBimbingan { @PrimaryGeneratedColumn("uuid") id: string; - @ManyToOne(() => Bimbingan, (bimbingan) => bimbingan.id) - bimbingan: Bimbingan; + @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) + @JoinColumn({ name: "idPendaftaran" }) + pendaftaran: PendaftaranTesis; + + @Column({ nullable: true }) + idPendaftaran: string; @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "idDosen" }) dosen: Pengguna; + + @Column({ nullable: true }) + idDosen: string; } diff --git a/src/entities/kelas.entity.ts b/src/entities/kelas.entity.ts deleted file mode 100644 index 00fcfc4d8fa30479dd969a1bfeb629b6bb72bc78..0000000000000000000000000000000000000000 --- a/src/entities/kelas.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Kelas { - @PrimaryGeneratedColumn("uuid") - id: string; -} diff --git a/src/entities/konfigurasi.entity.ts b/src/entities/konfigurasi.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..26ee3cee556d63ef6ae248c1f957afe0c56254f8 --- /dev/null +++ b/src/entities/konfigurasi.entity.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsString } from "class-validator"; +import { Column, Entity, PrimaryColumn } from "typeorm"; + +export enum KonfigurasiKeyEnum { + AWAL_PENDAFTARAN = "AWAL_PENDAFTARAN", + AKHIR_PENDAFTARAN = "AKHIR_PENDAFTARAN", + AWAL_SEMPRO = "AWAL_SEMPRO", + AKHIR_SEMPRO = "AKHIR_SEMPRO", + AWAL_SEM_TESIS = "AWAL_SEM_TESIS", + AKHIR_SEM_TESIS = "AKHIR_SEM_TESIS", + AWAL_SIDANG = "AWAL_SIDANG", + AKHIR_SIDANG = "AKHIR_SIDANG", +} + +@Entity() +export class Konfigurasi { + @ApiProperty({ enum: KonfigurasiKeyEnum }) + @IsEnum(KonfigurasiKeyEnum) + @PrimaryColumn({ type: "enum", enum: KonfigurasiKeyEnum }) + key: KonfigurasiKeyEnum; + + @ApiProperty() + @IsString() + @Column({ type: "text" }) + value: string; +} diff --git a/src/entities/mahasiswaKelas.ts b/src/entities/mahasiswaKelas.ts deleted file mode 100644 index 2c8422498639304cb86e2cb7f7eefacc3614b38d..0000000000000000000000000000000000000000 --- a/src/entities/mahasiswaKelas.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Kelas } from "./kelas.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class MahasiswaKelas { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Kelas, (kelas) => kelas.id) - kelas: Kelas; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; -} 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/pembimbingSidang.entity.ts b/src/entities/pembimbingSidang.entity.ts deleted file mode 100644 index a3d9dbec55b4d3e94d7a1e6d8b14262b10752e3d..0000000000000000000000000000000000000000 --- a/src/entities/pembimbingSidang.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 PembimbingSidang { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Sidang, (sidang) => sidang.id) - sidang: Sidang; - - @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..901bcd467d91ca787f2cbb58f0cb70f7ad19291b --- /dev/null +++ b/src/entities/pendaftaranSidsem.ts @@ -0,0 +1,101 @@ +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"; +import { BerkasSidsem } from "./berkasSidsem.entity"; +import { IsEnum, IsUUID } from "@nestjs/class-validator"; +import { IsDateString, IsNotEmpty, IsString } from "class-validator"; + +export enum TipeSidsemEnum { + SEMINAR_1 = "SEMINAR_1", + SEMINAR_2 = "SEMINAR_2", + SIDANG = "SIDANG", +} + +export enum SidsemStatus { + NOT_ASSIGNED = "NOT_ASSIGNED", + REJECTED = "REJECTED", + APPROVED = "APPROVED", +} + +@Entity() +export class PendaftaranSidsem { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @IsUUID() + @PrimaryGeneratedColumn("uuid") + id: string; + + @ApiProperty({ enum: TipeSidsemEnum }) + @IsEnum(TipeSidsemEnum) + @Column({ type: "enum", enum: TipeSidsemEnum }) + tipe: TipeSidsemEnum; + + @ApiProperty() + @IsEnum(SidsemStatus) + @Column({ + type: "enum", + enum: SidsemStatus, + default: SidsemStatus.NOT_ASSIGNED, + }) + status: SidsemStatus; + + @ApiProperty() + @IsDateString() + @Column({ type: "timestamptz", nullable: true }) + jadwal: Date; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @Column({ type: "text" }) + judulSidsem: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @Column({ type: "text" }) + deskripsiSidsem: string; + + @ManyToOne(() => PendaftaranTesis, (pendaftaranTesis) => pendaftaranTesis.id) + pendaftaranTesis: PendaftaranTesis; + + @ApiProperty() + @IsString() + @IsNotEmpty() + @Column({ type: "text", nullable: true }) + ruangan: string; + + @OneToMany(() => PengujiSidsem, (pengujiSidsem) => pengujiSidsem.sidsem) + penguji: PengujiSidsem[]; + + @ApiProperty({ type: [BerkasSidsem] }) + @OneToMany( + () => BerkasSidsem, + (berkasSidsem) => berkasSidsem.pendaftaranSidsem, + { + cascade: true, + }, + ) + berkasSidsem: BerkasSidsem[]; + + @ApiProperty() + @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) + waktuPengiriman: Date; +} + +export function cmpTipeSidsem(a: TipeSidsemEnum, b: TipeSidsemEnum): number { + const tipeSidsemOrder = { + [TipeSidsemEnum.SEMINAR_1]: 1, + [TipeSidsemEnum.SEMINAR_2]: 2, + [TipeSidsemEnum.SIDANG]: 3, + }; + + return tipeSidsemOrder[a] - tipeSidsemOrder[b]; +} diff --git a/src/entities/pendaftaranTesis.entity.ts b/src/entities/pendaftaranTesis.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9a8fa31c2f525bcc45c5d87cf250d018901ceda --- /dev/null +++ b/src/entities/pendaftaranTesis.entity.ts @@ -0,0 +1,80 @@ +import { + Column, + Entity, + JoinColumn, + 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", + INTERVIEW = "INTERVIEW", + APPROVED = "APPROVED", + REJECTED = "REJECTED", +} + +export enum JalurEnum { + CS = "CS", + SEI = "SEI", + IS = "IS", + IT = "IT", + INTS = "INTS", + MMT = "MMT", + CC = "CC", + DSAI = "DSAI", + CSEC = "CSEC", +} + +@Entity() +export class PendaftaranTesis { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @PrimaryGeneratedColumn("uuid") + id: string; + + @ApiProperty({ enum: JalurEnum }) + @Column({ type: "enum", enum: JalurEnum }) + jalurPilihan: JalurEnum; + + @ApiProperty() + @Column({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) + waktuPengiriman: Date; + + @ApiProperty() + @Column({ type: "timestamptz", nullable: true }) + jadwalInterview: Date; + + @ApiProperty() + @Column({ type: "timestamptz", nullable: true }) + waktuKeputusan: Date; + + @ApiProperty({ enum: RegStatus }) + @Column({ type: "enum", enum: RegStatus, default: RegStatus.NOT_ASSIGNED }) + status: RegStatus; + + @ApiProperty({ type: Topik }) + @ManyToOne(() => Topik, (topik) => topik.id, { cascade: true }) + topik: Topik; + + @ApiProperty() + @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "mahasiswaId" }) + mahasiswa: Pengguna; + + @Column() + mahasiswaId: string; + + @ApiProperty() + @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + penerima: Pengguna; + + @OneToMany( + () => DosenBimbingan, + (dosenBimbingan) => dosenBimbingan.pendaftaran, + ) + dosenBimbingan: DosenBimbingan[]; +} diff --git a/src/entities/pengajarKelas.entity.ts b/src/entities/pengajarKelas.entity.ts deleted file mode 100644 index ad5b1351996cf9c2dba15bcd5aff65d270fa1e74..0000000000000000000000000000000000000000 --- a/src/entities/pengajarKelas.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Kelas } from "./kelas.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class PengajarKelas { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Kelas, (kelas) => kelas.id) - kelas: Kelas; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - dosen: Pengguna; -} diff --git a/src/entities/pengajuanPengambilanTopik.entity.ts b/src/entities/pengajuanPengambilanTopik.entity.ts deleted file mode 100644 index 01a193286188f877065398c2749e17e4937ee797..0000000000000000000000000000000000000000 --- a/src/entities/pengajuanPengambilanTopik.entity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Topik } from "./topik.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class PengajuanPengambilanTopik { - @PrimaryGeneratedColumn("uuid") - id: string; - - @Column({ nullable: true }) - disahkan: boolean; - - @Column({ nullable: true }) - deskripsi: string; - - @Column() - jalurPilihan: string; - - @Column({ type: "timestamptz" }) - waktuPengiriman: Date; - - @Column({ type: "timestamptz", nullable: true }) - waktuPersetujuan: Date; - - @Column({ type: "timestamptz", nullable: true }) - jadwalInterview: Date; - - @Column({ type: "timestamptz", nullable: true }) - waktuPengesahan: Date; - - @Column({ type: "timestamptz", nullable: true }) - waktuPenolakan: Date; - - @ManyToOne(() => Topik, (topik) => topik.id) - topik: Topik; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - pembimbing: Pengguna; -} diff --git a/src/entities/pengambilanTopik.entity.ts b/src/entities/pengambilanTopik.entity.ts deleted file mode 100644 index 6e933ed1214f0e1312076963fd7270719c5acb9c..0000000000000000000000000000000000000000 --- a/src/entities/pengambilanTopik.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Topik } from "./topik.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class PengambilanTopik { - @PrimaryGeneratedColumn("uuid") - id: string; - - @Column() - deskripsi: string; - - @Column() - jalurPilihan: string; - - @ManyToOne(() => Topik, (topik) => topik.id) - topik: Topik; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - pembimbing: Pengguna; -} diff --git a/src/entities/pengguna.entity.ts b/src/entities/pengguna.entity.ts index 4ff51c2dcc29e2e6e0a30cf1aafd8e76303f910c..4018370c6e6f5550a3d8126b08ad179c9a78f0c4 100644 --- a/src/entities/pengguna.entity.ts +++ b/src/entities/pengguna.entity.ts @@ -1,19 +1,86 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { + ApiHideProperty, + ApiProperty, + ApiPropertyOptional, +} from "@nestjs/swagger"; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { PendaftaranTesis } from "./pendaftaranTesis.entity"; +import { DosenBimbingan } from "./dosenBimbingan.entity"; + +export enum RoleEnum { + ADMIN = "ADMIN", + S2_MAHASISWA = "S2_MAHASISWA", + S2_PEMBIMBING = "S2_PEMBIMBING", + S2_PENGUJI = "S2_PENGUJI", + S2_TIM_TESIS = "S2_TIM_TESIS", + S1_MAHASISWA = "S1_MAHASISWA", + S1_PEMBIMBING = "S1_PEMBIMBING", + S1_PENGUJI = "S1_PENGUJI", + S1_TIM_TA = "S1_TIM_TA", +} @Entity() export class Pengguna { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) @PrimaryGeneratedColumn("uuid") id: string; - @Column() + @ApiProperty() + @Column({ type: "text", nullable: true }) nama: string; - @Column() + @ApiProperty({ example: "13521999@mahasiswa.itb.ac.id" }) + @Column({ type: "text", unique: true }) email: string; - @Column() - status: string; + @ApiHideProperty() + @Column({ type: "text", nullable: true }) + password: string; + + @ApiPropertyOptional({ example: "13521999" }) + @Column({ type: "varchar", length: 8, nullable: true }) + nim: string; + + @ApiProperty({ enum: RoleEnum, isArray: true }) + @Column({ + type: "enum", + enum: RoleEnum, + array: true, + default: [], + }) + roles: RoleEnum[]; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + kontakWhatsApp: string; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + kontakMsTeams: string; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + kontakEmail: string; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + kontakTelp: string; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + kontakLainnya: string; + + @ApiPropertyOptional() + @Column({ type: "text", nullable: true }) + keahlian: string; + + @ApiHideProperty() + @Column({ type: "boolean", default: true }) + aktif: boolean; + + @OneToMany(() => PendaftaranTesis, (pendaftaran) => pendaftaran.mahasiswa) + pendaftaranTesis: PendaftaranTesis[]; - @Column("simple-array") - roles: string[]; + @OneToMany(() => DosenBimbingan, (dosen) => dosen.dosen) + dosenBimbingan: DosenBimbingan[]; } 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/pengujiSidsem.entity.ts b/src/entities/pengujiSidsem.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..934f0da7dbfc9520ee87e55c12492db4575a203b --- /dev/null +++ b/src/entities/pengujiSidsem.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { Pengguna } from "./pengguna.entity"; +import { PendaftaranSidsem } from "./pendaftaranSidsem"; + +@Entity() +export class PengujiSidsem { + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne( + () => PendaftaranSidsem, + (pendaftaranSidsem) => pendaftaranSidsem.id, + ) + @JoinColumn({ name: "idSidsem" }) + sidsem: PendaftaranSidsem; + + @Column() + idSidsem: string; + + @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) + @JoinColumn({ name: "idDosen" }) + dosen: Pengguna; + + @Column() + idDosen: string; +} diff --git a/src/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 deleted file mode 100644 index 97df4c092f30bd329d8c3ece86d385bd0e6300c2..0000000000000000000000000000000000000000 --- a/src/entities/ruangan.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Ruangan { - @PrimaryGeneratedColumn("uuid") - id: 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/topik.entity.ts b/src/entities/topik.entity.ts index e0da355499ef58e1592a7b2b2899d27201c7bb50..75bbc9703a7c3c994c434b72b9b392a047ff6300 100644 --- a/src/entities/topik.entity.ts +++ b/src/entities/topik.entity.ts @@ -1,14 +1,37 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; import { Pengguna } from "./pengguna.entity"; @Entity() export class Topik { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) @PrimaryGeneratedColumn("uuid") id: string; + @ApiProperty() @Column() judul: string; + @ApiProperty() + @Column({ type: "text" }) + deskripsi: string; + + @ApiProperty({ type: () => Pengguna }) @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - dosen: Pengguna; + @JoinColumn({ name: "idPengaju" }) + pengaju: Pengguna; + + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + @Column({ nullable: true }) + idPengaju: string; + + @ApiHideProperty() + @Column({ type: "boolean", default: true }) + aktif: boolean; } diff --git a/src/entities/tugas.entity.ts b/src/entities/tugas.entity.ts deleted file mode 100644 index c445104cee8dbc1688e474e84031487e8d35f385..0000000000000000000000000000000000000000 --- a/src/entities/tugas.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; -import { Kelas } from "./kelas.entity"; -import { Pengguna } from "./pengguna.entity"; - -@Entity() -export class Tugas { - @PrimaryGeneratedColumn("uuid") - id: string; - - @ManyToOne(() => Kelas, (kelas) => kelas.id) - kelas: Kelas; - - @ManyToOne(() => Pengguna, (pengguna) => pengguna.id) - mahasiswa: Pengguna; -} diff --git a/src/env.validation.ts b/src/env.validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d5e6d037f02d9f54e2c3c7a0af08adc63785ff6 --- /dev/null +++ b/src/env.validation.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from "class-transformer"; +import { + IsNumber, + validateSync, + Min, + Max, + IsString, + IsUrl, +} from "class-validator"; + +class EnvironmentVariables { + @IsString() + POSTGRES_HOST: string; + + @IsNumber() + @Min(0) + @Max(65535) + POSTGRES_PORT: number; + + @IsString() + POSTGRES_USER: string; + + @IsString() + POSTGRES_PASSWORD: string; + + @IsString() + POSTGRES_DATABASE: string; + + @IsString() + @IsUrl({ require_tld: false }) + AUTH_SERVICE_URL: string; + + @IsString() + @IsUrl({ require_tld: false }) + FE_URL: string; + + @IsString() + COOKIE_NAME: string; +} + +export function validate(config: Record<string, unknown>) { + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + const errors = validateSync(validatedConfig, { + skipMissingProperties: false, + }); + + if (errors.length > 0) { + throw new Error(errors.toString()); + } + return validatedConfig; +} diff --git a/src/helper/roles.ts b/src/helper/roles.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebd5385ec59c130f5baf20939e9d5162ba647136 --- /dev/null +++ b/src/helper/roles.ts @@ -0,0 +1,12 @@ +import { RoleEnum } from "src/entities/pengguna.entity"; + +export const HIGH_AUTHORITY_ROLES = [RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS]; +export const DOSEN = [RoleEnum.S2_PEMBIMBING, RoleEnum.S2_PENGUJI]; + +export function isHighAuthority(roles: RoleEnum[]) { + return roles.some((role) => HIGH_AUTHORITY_ROLES.includes(role)); +} + +export function isDosen(roles: RoleEnum[]) { + return roles.some((role) => DOSEN.includes(role)); +} diff --git a/src/helper/sorting.ts b/src/helper/sorting.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d171096c4e9d476f1e4d0349a762ecb158e4a07 --- /dev/null +++ b/src/helper/sorting.ts @@ -0,0 +1,11 @@ +import { OrderByCondition } from "typeorm"; + +export function generateQueryBuilderOrderByObj( + keyToColumnMapping: Record<string, string>, + key: string, + sort?: "ASC" | "DESC", +): OrderByCondition { + const orderByObj = {}; + orderByObj[keyToColumnMapping[key]] = sort ?? "DESC"; + return orderByObj; +} diff --git a/src/helper/validation.ts b/src/helper/validation.ts deleted file mode 100644 index cfc65eb722c4859bd1569749bccb0e8785721b77..0000000000000000000000000000000000000000 --- a/src/helper/validation.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NotFoundException } from "@nestjs/common"; -import { validate as uuidValidate } from "uuid"; - -interface ID { - id: string; - object: string; -} - -export function validateId(items: ID[]) { - for (const item of items) { - const isValidUUID = uuidValidate(item.id); - - if (!isValidUUID) { - throw new NotFoundException(`${item.object} not found.`); - } - } -} diff --git a/src/konfigurasi/konfigurasi.controller.ts b/src/konfigurasi/konfigurasi.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..caf6a97a7950a431351f9d130913c7be03783420 --- /dev/null +++ b/src/konfigurasi/konfigurasi.controller.ts @@ -0,0 +1,37 @@ +import { Body, Controller, Get, Put, UseGuards } from "@nestjs/common"; +import { KonfigurasiService } from "./konfigurasi.service"; +import { KonfigurasiArrDto, UpdateKonfigurasiResDto } from "./konfigurasi.dto"; +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"; + +@ApiTags("Konfigurasi") +@ApiCookieAuth() +@ApiBearerAuth() +@Controller("konfigurasi") +@UseGuards(CustomAuthGuard, RolesGuard) +@Roles(RoleEnum.ADMIN, RoleEnum.S2_TIM_TESIS) +export class KonfigurasiController { + constructor(private readonly konfigurasiService: KonfigurasiService) {} + + @ApiOkResponse({ type: UpdateKonfigurasiResDto }) + @Put() + async updateKonfigurasi( + @Body() data: KonfigurasiArrDto, + ): Promise<UpdateKonfigurasiResDto> { + return await this.konfigurasiService.updateKonfigurasi(data); + } + + @ApiOkResponse({ type: KonfigurasiArrDto }) + @Get() + async getKonfigurasi(): Promise<KonfigurasiArrDto> { + return this.konfigurasiService.getKonfigurasi(); + } +} diff --git a/src/konfigurasi/konfigurasi.dto.ts b/src/konfigurasi/konfigurasi.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ebdd10da2f421f1aabdd53afe02b26466269032 --- /dev/null +++ b/src/konfigurasi/konfigurasi.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ValidateNested } from "class-validator"; +import { + Konfigurasi, + KonfigurasiKeyEnum, +} from "src/entities/konfigurasi.entity"; + +export class KonfigurasiArrDto { + @ApiProperty({ type: [Konfigurasi] }) + @ValidateNested({ each: true }) + @Type(() => Konfigurasi) + data: Konfigurasi[]; +} + +export class UpdateKonfigurasiResDto { + @ApiProperty({ enum: KonfigurasiKeyEnum, isArray: true }) + keys: KonfigurasiKeyEnum[]; +} diff --git a/src/konfigurasi/konfigurasi.module.ts b/src/konfigurasi/konfigurasi.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a1fcc680131abd16772f7002a54c203572257be --- /dev/null +++ b/src/konfigurasi/konfigurasi.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { KonfigurasiController } from "./konfigurasi.controller"; +import { KonfigurasiService } from "./konfigurasi.service"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Konfigurasi } from "src/entities/konfigurasi.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Konfigurasi])], + controllers: [KonfigurasiController], + providers: [KonfigurasiService], + exports: [KonfigurasiService], +}) +export class KonfigurasiModule {} diff --git a/src/konfigurasi/konfigurasi.service.ts b/src/konfigurasi/konfigurasi.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..6702c21184cc55ec1124dc165b0abd80abf4ad43 --- /dev/null +++ b/src/konfigurasi/konfigurasi.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Konfigurasi, + KonfigurasiKeyEnum, +} from "src/entities/konfigurasi.entity"; +import { Repository } from "typeorm"; +import { KonfigurasiArrDto, UpdateKonfigurasiResDto } from "./konfigurasi.dto"; + +@Injectable() +export class KonfigurasiService { + constructor( + @InjectRepository(Konfigurasi) + private konfigurasiRepository: Repository<Konfigurasi>, + ) {} + + async updateKonfigurasi({ + data, + }: KonfigurasiArrDto): Promise<UpdateKonfigurasiResDto> { + await this.konfigurasiRepository.upsert(data, ["key"]); + const res = { + keys: data.map((d) => d.key), + }; + return res; + } + + async getKonfigurasi(): Promise<KonfigurasiArrDto> { + const data = await this.konfigurasiRepository.find(); + + return { data }; + } + + async getKonfigurasiByKey( + key: KonfigurasiKeyEnum, + ): Promise<string | undefined> { + const data = await this.konfigurasiRepository.findOne({ + where: { + key, + }, + }); + + return data?.value; + } +} diff --git a/src/main.ts b/src/main.ts index 2553930747b4e0e2f52ff0a42f7f34ff44f0c407..05e2e26f2ea78343514d74aec6917e2a357dad8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,39 @@ import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; +import * as cookieParser from "cookie-parser"; +import { ValidationPipe } from "@nestjs/common"; +import { ForbiddenExceptionFilter } from "./middlewares/forbidden-exception.filter"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.enableCors(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalFilters(new ForbiddenExceptionFilter()); + + app.use(cookieParser()); + app.enableCors({ + origin: process.env.FE_URL, + credentials: true, + }); + + const options = new DocumentBuilder() + .setTitle("GraduIT S2 API") + .setDescription("GraduIT API Documentation for S2 services") + .setVersion("1.0") + .addTag("Alokasi Topik") + .addTag("Bimbingan") + .addTag("Dashboard") + .addTag("Dosen Bimbingan") + .addTag("Dosen Penguji") + .addTag("Konfigurasi") + .addTag("Registrasi Tesis") + .addTag("Registrasi Sidang Seminar") + .addCookieAuth(process.env.COOKIE_NAME) + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup("api-docs", app, document); await app.listen(3000); } diff --git a/src/middlewares/custom-auth.guard.ts b/src/middlewares/custom-auth.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f43322bf540292bf6a667e30f790ae768c426f2 --- /dev/null +++ b/src/middlewares/custom-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class CustomAuthGuard extends AuthGuard("custom") {} diff --git a/src/middlewares/custom.strategy.ts b/src/middlewares/custom.strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b1e54d2d394064f285c26dcdcb2472cc35f54ae --- /dev/null +++ b/src/middlewares/custom.strategy.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Request } from "express"; +import { Strategy } from "passport-custom"; +import { AuthService } from "src/auth/auth.service"; + +@Injectable() +export class CustomStrategy extends PassportStrategy(Strategy, "custom") { + constructor(private authService: AuthService) { + super(); + } + + async validate(req: Request) { + let token = ""; + + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + return this.authService.validate(token); + } +} diff --git a/src/middlewares/forbidden-exception.filter.ts b/src/middlewares/forbidden-exception.filter.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d6de0d98638596e89d2c171d2c28dbbc145c205 --- /dev/null +++ b/src/middlewares/forbidden-exception.filter.ts @@ -0,0 +1,21 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + ForbiddenException, +} from "@nestjs/common"; +import { Response } from "express"; + +@Catch(ForbiddenException) +export class ForbiddenExceptionFilter implements ExceptionFilter { + catch(exception: ForbiddenException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse<Response>(); + const status = exception.getStatus(); + + response + .clearCookie(process.env.COOKIE_NAME) + .status(status) + .json(exception.getResponse()); + } +} diff --git a/src/middlewares/roles.decorator.ts b/src/middlewares/roles.decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..27f28e876f3a010624b9e3d1c5fa7958845f1d1f --- /dev/null +++ b/src/middlewares/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common"; +import { RoleEnum } from "src/entities/pengguna.entity"; + +export const ROLES_KEY = "roles"; +export const Roles = (...roles: RoleEnum[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/middlewares/roles.guard.ts b/src/middlewares/roles.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..9579c69121750015ae954ccf0c33809a2dd5c9b1 --- /dev/null +++ b/src/middlewares/roles.guard.ts @@ -0,0 +1,23 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { ROLES_KEY } from "./roles.decorator"; +import { AuthDto } from "src/auth/auth.dto"; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise<boolean> { + const requiredRoles = this.reflector.getAllAndOverride<RoleEnum[]>( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles) return true; + + const { roles } = context.switchToHttp().getRequest().user as AuthDto; + + return requiredRoles.some((role) => roles?.includes(role)); + } +} diff --git a/src/pengguna/pengguna.module.ts b/src/pengguna/pengguna.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e5276cbb4849fcc7453b7b2618834fcd3a2f3c7 --- /dev/null +++ b/src/pengguna/pengguna.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PenggunaService } from "./pengguna.service"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Pengguna } from "src/entities/pengguna.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Pengguna])], + providers: [PenggunaService], + exports: [PenggunaService], +}) +export class PenggunaModule {} diff --git a/src/pengguna/pengguna.service.ts b/src/pengguna/pengguna.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..28281715431f789c616d7213ec8a8cea18b7c80c --- /dev/null +++ b/src/pengguna/pengguna.service.ts @@ -0,0 +1,24 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { ArrayContains, Repository } from "typeorm"; + +@Injectable() +export class PenggunaService { + constructor( + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + ) {} + + async isMahasiswaAktifOrFail(id: string) { + const mhs = await this.penggunaRepo.findOneBy({ + id, + aktif: true, + roles: ArrayContains([RoleEnum.S2_MAHASISWA]), + }); + + if (!mhs) { + throw new NotFoundException("Mahasiswa aktif tidak ditemukan"); + } + } +} diff --git a/src/registrasi-sidsem/registrasi-sidsem.controller.ts b/src/registrasi-sidsem/registrasi-sidsem.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..e66b83522cabfb3d370827bf65798e1ca5fd0fd1 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.controller.ts @@ -0,0 +1,154 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiCookieAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { CustomAuthGuard } from "src/middlewares/custom-auth.guard"; +import { Roles } from "src/middlewares/roles.decorator"; +import { RolesGuard } from "src/middlewares/roles.guard"; +import { + CreatePengajuanSidsemDto, + GetAllPengajuanSidangReqQueryDto, + GetAllPengajuanSidangRespDto, + GetOnePengajuanSidangRespDto, + PengajuanSidsemIdDto, + SidsemMhsIdParamDto, + UpdateSidsemDetailDto, + UpdateSidsemStatusDto, +} from "./registrasi-sidsem.dto"; +import { RegistrasiSidsemService } from "./registrasi-sidsem.service"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +import { RoleEnum } from "src/entities/pengguna.entity"; +import { + DOSEN, + HIGH_AUTHORITY_ROLES, + isDosen, + isHighAuthority, +} from "src/helper/roles"; + +@ApiTags("Registrasi Sidang Seminar") +@ApiBearerAuth() +@ApiCookieAuth() +@UseGuards(CustomAuthGuard, RolesGuard) +@Controller("registrasi-sidsem") +export class RegistrasiSidsemController { + constructor(private readonly regisSidsemService: RegistrasiSidsemService) {} + + @ApiOperation({ + summary: "Create new sidang seminar registration. Roles: S2_MAHASISWA", + }) + @ApiCreatedResponse({ type: PengajuanSidsemIdDto }) + @Roles(RoleEnum.S2_MAHASISWA) + @Post() + async create(@Req() req: Request, @Body() dto: CreatePengajuanSidsemDto) { + const { id } = req.user as AuthDto; + return this.regisSidsemService.create(id, dto); + } + + @ApiOperation({ + summary: + "Get all newest sidang seminar registration per mhs. Roles: ADMIN, S2_TIM_TESIS, S2_PEMBIMBING, S2_PENGUJI", + }) + @ApiOkResponse({ type: GetAllPengajuanSidangRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, ...DOSEN) + @Get() + async findAll( + @Req() req: Request, + @Query() query: GetAllPengajuanSidangReqQueryDto, + ) { + const { id, roles } = req.user as AuthDto; + + if (!roles.includes(query.view)) { + throw new ForbiddenException(); + } + + return this.regisSidsemService.findAll( + query, + query.view === RoleEnum.S2_PEMBIMBING ? id : undefined, + query.view === RoleEnum.S2_PENGUJI ? id : undefined, + ); + } + + @ApiOperation({ + summary: + "Get newest sidang seminar registration per mhs. Roles: ADMIN, S2_TIM_TESIS, S2_PEMBIMBING, S2_PENGUJI, S2_MAHASISWA", + }) + @ApiOkResponse({ type: GetOnePengajuanSidangRespDto }) + @Roles(...HIGH_AUTHORITY_ROLES, ...DOSEN, RoleEnum.S2_MAHASISWA) + @Get("/mahasiswa/:mhsId") + async findOne(@Req() req: Request, @Param() param: SidsemMhsIdParamDto) { + let idPenguji = undefined; + let idPembimbing = undefined; + + const { roles, id } = req.user as AuthDto; + + if (!isHighAuthority(roles)) { + if (roles.includes(RoleEnum.S2_PEMBIMBING)) { + idPembimbing = id; + } + + if (roles.includes(RoleEnum.S2_PENGUJI)) { + idPenguji = id; + } + + if (!isDosen(roles) && id !== param.mhsId) { + // user is mahasiswa + throw new ForbiddenException("Ini bukan data Anda."); + } + } + return this.regisSidsemService.findOne( + param.mhsId, + idPembimbing, + idPenguji, + ); + } + + @ApiOperation({ + summary: "Update status sidang seminar. Roles: ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: PengajuanSidsemIdDto }) + @Roles(...HIGH_AUTHORITY_ROLES) + @Patch("/mahasiswa/:mhsId/status") + async updateStatus( + @Param() param: SidsemMhsIdParamDto, + @Body() updateDto: UpdateSidsemStatusDto, + @Req() req: Request, + ) { + return this.regisSidsemService.updateStatus( + param.mhsId, + updateDto.status, + req, + ); + } + + @ApiOperation({ + summary: + "Update detail of approved sidang seminar. Any falsify valued field will be ignored. Roles: ADMIN, S2_TIM_TESIS", + }) + @ApiOkResponse({ type: PengajuanSidsemIdDto }) + @Roles(...HIGH_AUTHORITY_ROLES) + @Patch("/mahasiswa/:mhsId/detail") + async updateDetail( + @Param() param: SidsemMhsIdParamDto, + @Body() updateDto: UpdateSidsemDetailDto, + @Req() req: Request, + ) { + return this.regisSidsemService.updateDetail(param.mhsId, updateDto, req); + } +} diff --git a/src/registrasi-sidsem/registrasi-sidsem.dto.ts b/src/registrasi-sidsem/registrasi-sidsem.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbead66ec4da85fd3d5ff7d5b537d1a8f873c361 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.dto.ts @@ -0,0 +1,177 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PickType, +} from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsDateString, + IsEnum, + IsNumberString, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { + PendaftaranSidsem, + SidsemStatus, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { JalurEnum } from "src/entities/pendaftaranTesis.entity"; +import { RoleEnum } from "src/entities/pengguna.entity"; + +export class SidsemViewQueryDto { + @IsEnum([ + RoleEnum.S2_PEMBIMBING, + RoleEnum.ADMIN, + RoleEnum.S2_TIM_TESIS, + RoleEnum.S2_PENGUJI, + ]) + @ApiProperty({ + enum: [ + RoleEnum.S2_PEMBIMBING, + RoleEnum.ADMIN, + RoleEnum.S2_TIM_TESIS, + RoleEnum.S2_PENGUJI, + ], + }) + view: + | RoleEnum.S2_PEMBIMBING + | RoleEnum.ADMIN + | RoleEnum.S2_TIM_TESIS + | RoleEnum.S2_PENGUJI; +} + +export class GetAllPengajuanSidangReqQueryDto extends SidsemViewQueryDto { + @ApiPropertyOptional() + @IsString() + @IsOptional() + search?: string; + + @ApiPropertyOptional({ enum: TipeSidsemEnum }) + @IsEnum(TipeSidsemEnum) + @IsOptional() + jenisSidang?: TipeSidsemEnum; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: 1" }) + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional({ description: "default: no limit" }) + limit?: number; +} + +export class GetAllPengajuanSidangItemDto { + @ApiProperty({ example: "550e8400-e29b-41d4-a716-446655440000" }) + idPengajuanSidsem: string; + + @ApiProperty() + idMahasiswa: string; + + @ApiProperty() + nimMahasiswa: string; + + @ApiProperty() + namaMahasiswa: string; + + @ApiProperty({ nullable: true }) + jadwalSidang: string | null; + + @ApiProperty({ enum: TipeSidsemEnum }) + jenisSidang: TipeSidsemEnum; + + @ApiProperty({ nullable: true }) + ruangan: string | null; + + @ApiProperty({ enum: SidsemStatus }) + status: SidsemStatus; + + @ApiProperty({ type: [String] }) + dosenPembimbing: string[]; + + @ApiProperty({ type: [BerkasSidsem] }) + berkasSidsem: BerkasSidsem[]; +} + +export class GetAllPengajuanSidangRespDto { + @ApiProperty() + total: number; + + @ApiProperty({ type: GetAllPengajuanSidangItemDto, isArray: true }) + data: GetAllPengajuanSidangItemDto[]; +} + +export class GetOnePengajuanSidangRespDto extends GetAllPengajuanSidangItemDto { + @ApiProperty() + emailMahasiswa: string; + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; + @ApiProperty() + judulTopik: string; + @ApiProperty() + deskripsiTopik: string; + @ApiProperty({ type: [String] }) + dosenPembimbing: string[]; + @ApiProperty({ type: [String] }) + dosenPenguji: string[]; + + @ApiProperty() + judulSidsem: string; + + @ApiProperty() + deskripsiSidsem: string; +} + +class BerkasSidsemWithoutId extends OmitType(BerkasSidsem, ["id"] as const) {} + +export class CreatePengajuanSidsemDto extends PickType(PendaftaranSidsem, [ + "judulSidsem", + "deskripsiSidsem", + "tipe", +]) { + @ApiProperty({ type: [BerkasSidsemWithoutId] }) + @ValidateNested({ each: true }) + @ArrayNotEmpty() + @Type(() => BerkasSidsemWithoutId) + berkasSidsem: BerkasSidsemWithoutId[]; +} + +export class PengajuanSidsemIdDto extends PickType(PendaftaranSidsem, [ + "id", +] as const) {} + +export class UpdateSidsemDetailDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + ruangan?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + jadwal?: Date; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID("all", { each: true }) + dosenPengujiIds?: string[]; +} + +export class SidsemMhsIdParamDto { + @IsUUID() + @ApiProperty() + mhsId: string; +} + +export class UpdateSidsemStatusDto { + @IsEnum([SidsemStatus.APPROVED, SidsemStatus.REJECTED]) + @ApiProperty({ enum: [SidsemStatus.APPROVED, SidsemStatus.REJECTED] }) + status: SidsemStatus.APPROVED | SidsemStatus.REJECTED; +} diff --git a/src/registrasi-sidsem/registrasi-sidsem.module.ts b/src/registrasi-sidsem/registrasi-sidsem.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..db10743fedc136a73bebabfa779804b49133f606 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.module.ts @@ -0,0 +1,48 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { AuthModule } from "src/auth/auth.module"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { PendaftaranSidsem } from "src/entities/pendaftaranSidsem"; +import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; +import { Pengguna } from "src/entities/pengguna.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { RegistrasiSidsemController } from "./registrasi-sidsem.controller"; +import { RegistrasiSidsemService } from "./registrasi-sidsem.service"; +import { RegistrasiTesisService } from "src/registrasi-tesis/registrasi-tesis.service"; +import { RegistrasiTesisModule } from "src/registrasi-tesis/registrasi-tesis.module"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { Topik } from "src/entities/topik.entity"; +import { PenggunaModule } from "src/pengguna/pengguna.module"; +import { PengujiSidsem } from "src/entities/pengujiSidsem.entity"; +import { KonfigurasiModule } from "src/konfigurasi/konfigurasi.module"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { Konfigurasi } from "src/entities/konfigurasi.entity"; +import { HttpModule } from "@nestjs/axios"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + PendaftaranSidsem, + DosenBimbingan, + PendaftaranTesis, + Pengguna, + BerkasSidsem, + Topik, + PengujiSidsem, + Konfigurasi, + ]), + AuthModule, + RegistrasiTesisModule, + PenggunaModule, + KonfigurasiModule, + HttpModule, + ], + controllers: [RegistrasiSidsemController], + providers: [ + RegistrasiSidsemService, + CustomStrategy, + RegistrasiTesisService, + KonfigurasiService, + ], +}) +export class RegistrasiSidsemModule {} diff --git a/src/registrasi-sidsem/registrasi-sidsem.service.ts b/src/registrasi-sidsem/registrasi-sidsem.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f1b73a3907008766d8dfb9f34917b29283242e0 --- /dev/null +++ b/src/registrasi-sidsem/registrasi-sidsem.service.ts @@ -0,0 +1,529 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + cmpTipeSidsem, + PendaftaranSidsem, + SidsemStatus, + TipeSidsemEnum, +} from "src/entities/pendaftaranSidsem"; +import { PengujiSidsem } from "src/entities/pengujiSidsem.entity"; +import { DataSource, In, Repository, Brackets } from "typeorm"; +import { + CreatePengajuanSidsemDto, + GetAllPengajuanSidangItemDto, + GetAllPengajuanSidangReqQueryDto, + GetAllPengajuanSidangRespDto, + GetOnePengajuanSidangRespDto, + PengajuanSidsemIdDto, + UpdateSidsemDetailDto, +} from "./registrasi-sidsem.dto"; +import { RegStatus } from "src/entities/pendaftaranTesis.entity"; +import { RegistrasiTesisService } from "src/registrasi-tesis/registrasi-tesis.service"; +import { BerkasSidsem } from "src/entities/berkasSidsem.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; +import { KonfigurasiService } from "src/konfigurasi/konfigurasi.service"; +import { KonfigurasiKeyEnum } from "src/entities/konfigurasi.entity"; +import * as dayjs from "dayjs"; +import { HttpService } from "@nestjs/axios"; +import { Request } from "express"; +import { firstValueFrom } from "rxjs"; + +@Injectable() +export class RegistrasiSidsemService { + constructor( + @InjectRepository(PendaftaranSidsem) + private pendaftaranSidsemRepo: Repository<PendaftaranSidsem>, + @InjectRepository(PengujiSidsem) + private pengujiSidsemRepo: Repository<PengujiSidsem>, + @InjectRepository(Pengguna) + private penggunaRepo: Repository<Pengguna>, + @InjectRepository(BerkasSidsem) + private berkasSidsemRepo: Repository<BerkasSidsem>, + private regTesisService: RegistrasiTesisService, + private dataSource: DataSource, + private konfService: KonfigurasiService, + private httpService: HttpService, + ) {} + + private async getLatestPendaftaranSidsem(mhsId: string) { + return await this.pendaftaranSidsemRepo + .createQueryBuilder("ps") + .select([ + "ps.id", + "ps.tipe", + "ps.jadwal", + "ps.ruangan", + "ps.status", + "ps.judulSidsem", + "ps.deskripsiSidsem", + "berkasSidsem", + "pt.id", + "pt.jalurPilihan", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "mahasiswa.email", + "topik.judul", + "topik.deskripsi", + "dosenBimbingan.id", + "dosen.id", + "dosen.nama", + "penguji.id", + "dosenPenguji.id", + "dosenPenguji.nama", + ]) + .leftJoin("ps.penguji", "penguji") + .leftJoin("ps.berkasSidsem", "berkasSidsem") + .leftJoin("penguji.dosen", "dosenPenguji") + .leftJoin("ps.pendaftaranTesis", "pt") + .leftJoin("pt.mahasiswa", "mahasiswa") + .leftJoin("pt.topik", "topik") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .where("pt.mahasiswaId = :mhsId", { mhsId }) + .andWhere("mahasiswa.aktif = true") + .orderBy("ps.waktuPengiriman", "DESC") + .getOne(); + } + + konfKeysMapping = { + [TipeSidsemEnum.SEMINAR_1]: { + start: KonfigurasiKeyEnum.AWAL_SEMPRO, + end: KonfigurasiKeyEnum.AKHIR_SEMPRO, + }, + [TipeSidsemEnum.SEMINAR_2]: { + start: KonfigurasiKeyEnum.AWAL_SEM_TESIS, + end: KonfigurasiKeyEnum.AKHIR_SEM_TESIS, + }, + [TipeSidsemEnum.SIDANG]: { + start: KonfigurasiKeyEnum.AWAL_SIDANG, + end: KonfigurasiKeyEnum.AKHIR_SIDANG, + }, + }; + + private async getSidsemKonfOrFail(tipe: TipeSidsemEnum) { + const mapping = this.konfKeysMapping[tipe]; + const [start, end] = await Promise.all([ + this.konfService.getKonfigurasiByKey(mapping.start), + this.konfService.getKonfigurasiByKey(mapping.end), + ]); + + if (!start || !end) { + throw new BadRequestException( + `Sidang seminar bertipe ${tipe} belum dikonfigurasi`, + ); + } + + return { start: new Date(start), end: new Date(end) }; + } + + async create( + mhsId: string, + dto: CreatePengajuanSidsemDto, + ): Promise<PengajuanSidsemIdDto> { + const { start, end } = await this.getSidsemKonfOrFail(dto.tipe); + + if ( + dayjs(new Date()).isBefore(dayjs(start).startOf("d")) || + dayjs(new Date()).isAfter(dayjs(end).endOf("d")) + ) { + throw new BadRequestException( + "Sidang seminar belum dibuka atau sudah ditutup", + ); + } + + const regTesis = await this.regTesisService.getNewestRegByMhsOrFail(mhsId); + + if (regTesis.status !== RegStatus.APPROVED) { + throw new BadRequestException( + "Mahasiswa belum diterima sebagai mahasiswa tesis.", + ); + } + + // Check if mahasiswa already has pending registration + const lastPendaftaran = await this.getLatestPendaftaranSidsem(mhsId); + if (lastPendaftaran) { + const delta = cmpTipeSidsem(dto.tipe, lastPendaftaran.tipe); + + if ( + (delta !== 0 && delta !== 1) || + (delta === 0 && lastPendaftaran.status !== SidsemStatus.REJECTED) || + (delta === 1 && lastPendaftaran.status !== SidsemStatus.APPROVED) + ) { + { + throw new BadRequestException("Tipe sidsem invalid"); + } + } + } else { + if (dto.tipe !== TipeSidsemEnum.SEMINAR_1) { + throw new BadRequestException("Tipe sidsem invalid"); + } + } + + const berkasSidsem = dto.berkasSidsem.map((berkasSubmisiTugas) => + this.berkasSidsemRepo.create(berkasSubmisiTugas), + ); + + // Create new registration + const createdRegistration = this.pendaftaranSidsemRepo.create({ + ...dto, + pendaftaranTesis: regTesis, + berkasSidsem, + }); + + await this.pendaftaranSidsemRepo.save(createdRegistration); + + return { + id: createdRegistration.id, + }; + } + + async findAll( + query: GetAllPengajuanSidangReqQueryDto, + idPembimbing?: string, + idPenguji?: string, + ): Promise<GetAllPengajuanSidangRespDto> { + const baseQuery = this.pendaftaranSidsemRepo + .createQueryBuilder("ps") + .select([ + "ps.id", + "ps.tipe", + "ps.status", + "pt.id", + "mahasiswa.id", + "mahasiswa.nim", + "mahasiswa.nama", + "dosenBimbingan.id", + "dosen.id", + "dosen.nama", + "berkasSidsem", + ]) + .innerJoinAndSelect( + (qb) => + qb + .select([ + "ps.pendaftaranTesisId AS latest_pendaftaranTesisId", + "MAX(ps.waktuPengiriman) AS latestPengiriman", + ]) + .from(PendaftaranSidsem, "ps") + .groupBy("ps.pendaftaranTesisId"), + "latest", + "latest.latest_pendaftaranTesisId = ps.pendaftaranTesisId AND ps.waktuPengiriman = latest.latestPengiriman", + ) + .leftJoin("ps.pendaftaranTesis", "pt") + .leftJoin("ps.berkasSidsem", "berkasSidsem") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .leftJoin("pt.mahasiswa", "mahasiswa") + .where("mahasiswa.aktif = true") + .orderBy("ps.waktuPengiriman", "DESC"); + + if (idPembimbing) { + baseQuery + .innerJoin("pt.dosenBimbingan", "dosenBimbinganFilter") + .andWhere("dosenBimbinganFilter.idDosen = :idPembimbing", { + idPembimbing, + }); + } + + if (idPenguji) { + baseQuery + .innerJoin("ps.penguji", "pengujiFilter") + .andWhere("pengujiFilter.idDosen = :idPenguji", { + idPenguji, + }); + } + + if (query.search) { + baseQuery.andWhere( + new Brackets((qb) => + qb + .where("mahasiswa.nama ILIKE :search", { + search: `%${query.search}%`, + }) + .orWhere("mahasiswa.nim ILIKE :search", { + search: `%${query.search}%`, + }), + ), + ); + } + + if (query.jenisSidang) { + baseQuery.andWhere("ps.jenisSidang = :jenisSidang", { + jenisSidang: query.jenisSidang, + }); + } + + if (query.limit) { + baseQuery.take(query.limit); + baseQuery.skip((query.page - 1) * query.limit); + } + + const [queryData, total] = await baseQuery.getManyAndCount(); + + const data: GetAllPengajuanSidangItemDto[] = queryData.map((res) => ({ + idPengajuanSidsem: res.id, + idMahasiswa: res.pendaftaranTesis.mahasiswa.id, + nimMahasiswa: res.pendaftaranTesis.mahasiswa.nim, + namaMahasiswa: res.pendaftaranTesis.mahasiswa.nama, + jadwalSidang: !!res.jadwal ? res.jadwal.toISOString() : null, + jenisSidang: res.tipe, + ruangan: res.ruangan, + status: res.status, + dosenPembimbing: res.pendaftaranTesis.dosenBimbingan.map( + (dosen) => dosen.dosen.nama, + ), + berkasSidsem: res.berkasSidsem, + })); + + return { data, total }; + } + + async findOne( + mhsId: string, + idPembimbing?: string, + idPenguji?: string, + ): Promise<GetOnePengajuanSidangRespDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest) { + throw new NotFoundException("Pendaftaran sidsem tidak ditemukan"); + } + + function isPembimbing() { + return latest.pendaftaranTesis.dosenBimbingan.some( + ({ dosen: { id } }) => id === idPembimbing, + ); + } + + function isPenguji() { + return latest.penguji.some(({ dosen: { id } }) => id === idPenguji); + } + + if (idPembimbing && idPenguji) { + if (!isPembimbing() && !isPenguji()) { + throw new ForbiddenException( + "Anda tidak terdaftar sebagai pembimbing atau penguji", + ); + } + } else if (idPembimbing) { + if (!isPembimbing()) { + throw new ForbiddenException("Anda tidak terdaftar sebagai pembimbing"); + } + } else if (idPenguji) { + if (!isPenguji()) { + throw new ForbiddenException("Anda tidak terdaftar sebagai penguji"); + } + } + + const data: GetOnePengajuanSidangRespDto = { + idPengajuanSidsem: latest.id, + idMahasiswa: latest.pendaftaranTesis.mahasiswa.id, + nimMahasiswa: latest.pendaftaranTesis.mahasiswa.nim, + namaMahasiswa: latest.pendaftaranTesis.mahasiswa.nama, + emailMahasiswa: latest.pendaftaranTesis.mahasiswa.email, + jadwalSidang: latest.jadwal ? latest.jadwal.toISOString() : null, + jenisSidang: latest.tipe, + ruangan: latest.ruangan, + jalurPilihan: latest.pendaftaranTesis.jalurPilihan, + judulTopik: latest.pendaftaranTesis.topik.judul, + deskripsiTopik: latest.pendaftaranTesis.topik.deskripsi, + status: latest.status, + berkasSidsem: latest.berkasSidsem, + judulSidsem: latest.judulSidsem, + deskripsiSidsem: latest.deskripsiSidsem, + dosenPembimbing: latest.pendaftaranTesis.dosenBimbingan.map( + ({ dosen: { nama } }) => nama, + ), + dosenPenguji: latest.penguji.map(({ dosen: { nama } }) => nama), + }; + + return data; + } + + async updateStatus( + mhsId: string, + status: SidsemStatus.REJECTED | SidsemStatus.APPROVED, + req: Request, + ): Promise<PengajuanSidsemIdDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest || latest.status !== SidsemStatus.NOT_ASSIGNED) { + throw new BadRequestException( + "Pendaftaran sidsem yang pending tidak ditemukan", + ); + } + + let token = ""; + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + const { data: notif } = await firstValueFrom( + this.httpService.post( + `${process.env.AUTH_SERVICE_URL}/notifikasi`, + { + title: `Pendaftaran ${latest.tipe.split("_").join(" ").toLowerCase()} Anda ${status === SidsemStatus.APPROVED ? "diterima" : "ditolak"}`, + description: `Pendaftaran tesis Anda ${status === SidsemStatus.APPROVED ? "diterima" : "ditolak"}. Silahkan periksa kembali data Anda untuk mengetahui lebih lanjut.`, + penggunaId: mhsId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + try { + await this.pendaftaranSidsemRepo.update(latest.id, { + status, + }); + } catch { + await firstValueFrom( + this.httpService.delete( + `${process.env.AUTH_SERVICE_URL}/notifikasi/${notif.id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + } + + return { id: latest.id } as PengajuanSidsemIdDto; + } + + async updateDetail( + mhsId: string, + updateDto: UpdateSidsemDetailDto, + req: Request, + ): Promise<PengajuanSidsemIdDto> { + const latest = await this.getLatestPendaftaranSidsem(mhsId); + + if (!latest || latest.status !== SidsemStatus.APPROVED) { + throw new BadRequestException( + "Pendaftaran sidsem yang disetujui tidak ditemukan", + ); + } + + if (updateDto.jadwal) { + if (dayjs(updateDto.jadwal).isBefore(dayjs(new Date()).endOf("d"))) { + throw new BadRequestException("Jadwal sidang tidak valid"); + } + } + + if (updateDto.dosenPengujiIds) { + const newPengujiList = await this.penggunaRepo.findBy({ + id: In(updateDto.dosenPengujiIds), + }); + + if ( + newPengujiList.length !== updateDto.dosenPengujiIds.length || + newPengujiList.some( + (dosen) => !dosen.roles.includes(RoleEnum.S2_PENGUJI), + ) + ) + throw new BadRequestException( + "Dosen id list contains invalid user ids", + ); + + const currentPenguji = await this.pengujiSidsemRepo.findBy({ + idSidsem: latest.id, + }); + + const newPengujiIds = newPengujiList.map((dosen) => dosen.id); + const currentPengujiIds = currentPenguji.map( + (currentPembimbing) => currentPembimbing.idDosen, + ); + + const idsToBeAdded = newPengujiIds.filter( + (newId) => !currentPengujiIds.includes(newId), + ); + + const idsToBeDeleted = currentPengujiIds.filter( + (newId) => !newPengujiIds.includes(newId), + ); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + let token = ""; + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + const { data: notif } = await firstValueFrom( + this.httpService.post( + `${process.env.AUTH_SERVICE_URL}/notifikasi`, + { + title: `Detail pendaftaran ${latest.tipe.split("_").join(" ").toLowerCase()} Anda diubah`, + description: `Detail pendaftaran ${latest.tipe.split("_").join(" ").toLowerCase()} Anda diubah. Silahkan periksa kembali data Anda untuk mengetahui lebih lanjut.`, + penggunaId: mhsId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + try { + await queryRunner.manager.insert( + PengujiSidsem, + idsToBeAdded.map((idDosen) => ({ sidsem: latest, idDosen })), + ); + await queryRunner.manager.delete(PengujiSidsem, { + idDosen: In(idsToBeDeleted), + }); + + if (updateDto.ruangan || updateDto.jadwal) { + await queryRunner.manager.update(PendaftaranSidsem, latest.id, { + ruangan: updateDto.ruangan, + jadwal: updateDto.jadwal, + }); + } + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + await firstValueFrom( + this.httpService.delete( + `${process.env.AUTH_SERVICE_URL}/notifikasi/${notif.id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } else { + await this.pendaftaranSidsemRepo.update(latest.id, { + ...updateDto, + }); + } + + return { id: latest.id } as PengajuanSidsemIdDto; + } +} diff --git a/src/registrasi-tesis/registrasi-tesis.controller.ts b/src/registrasi-tesis/registrasi-tesis.controller.ts index 18284cfb10c4eb44e3f38a2adc6b352d90696472..d7585fc17933028c4352b0d2f132a807b883a1a3 100644 --- a/src/registrasi-tesis/registrasi-tesis.controller.ts +++ b/src/registrasi-tesis/registrasi-tesis.controller.ts @@ -1,27 +1,270 @@ -import { Body, Controller, Post, Get, Param } from "@nestjs/common"; +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCookieAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; +import { Request } from "express"; +import { AuthDto } from "src/auth/auth.dto"; +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 { + FindAllNewestRegRespDto, + GetByIdRespDto, + IdDto, + RegByMhsParamDto, + RegDto, + RegQueryDto, + RegStatisticsRespDto, + UpdateByMhsParamsDto, + UpdateInterviewBodyDto, + UpdatePembimbingBodyDto, + UpdateStatusBodyDto, + ViewQueryDto, +} from "./registrasi-tesis.dto"; import { RegistrasiTesisService } from "./registrasi-tesis.service"; -import { RegistrasiTopikDto } from "src/dto/registrasi-topik"; +@ApiCookieAuth() +@ApiBearerAuth() +@ApiTags("Registrasi Tesis") @Controller("registrasi-tesis") export class RegistrasiTesisController { constructor( private readonly registrasiTesisService: RegistrasiTesisService, ) {} - // TODO: Protect using roles and guards + @ApiOperation({ + summary: "Create new registration. Roles: S2_MAHASISWA", + }) + @ApiCreatedResponse({ type: IdDto }) + @ApiNotFoundResponse({ description: "Penerima atau topik tidak ditemukan" }) + @ApiBadRequestResponse({ + description: + "Mahasiswa sedang memiliki pendaftaran aktif atau judul dan deskripsi topik baru tidak ada", + }) + @UseGuards(CustomAuthGuard, RolesGuard) + @Roles(RoleEnum.S2_MAHASISWA) + @Post() + async createTopicRegistration( + @Body() topicRegistrationDto: RegDto, + @Req() req: Request, + ): Promise<IdDto> { + const { id } = req.user as AuthDto; + return this.registrasiTesisService.createTopicRegistration( + id, + topicRegistrationDto, + ); + } + + @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") - findByUserId(@Param() params: { mahasiswaId: string }) { - return this.registrasiTesisService.findByUserId(params.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(); + } + } + + return this.registrasiTesisService.findByUserId( + params.mahasiswaId, + false, + undefined, + ); } - @Post() - async createTopicRegistration( - @Body() topicRegistrationDto: RegistrasiTopikDto, + @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, ) { - return this.registrasiTesisService.createTopicRegistration( - "ae9697b9-590f-4820-826b-948f5e746ca7", // TODO: Get user id from request, for now use generated UUID - topicRegistrationDto, + 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 res = await this.registrasiTesisService.findByUserId( + params.mahasiswaId, + 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) + @Get("/statistics") + async getRegsStatistics(@Req() req: Request, @Query() query: ViewQueryDto) { + const { id: idPenerima, roles } = req.user as AuthDto; + + if (!roles.includes(query.view)) { + throw new ForbiddenException(); + } + + return this.registrasiTesisService.getRegsStatistics({ + idPenerima: query.view == RoleEnum.S2_PEMBIMBING ? idPenerima : undefined, + }); + } + + // 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) + @Get() + async findAllNewest( + @Query() + query: RegQueryDto, + @Req() req: Request, + ) { + const { id: idPenerima, roles } = req.user as AuthDto; + + if (!roles.includes(query.view)) { + throw new ForbiddenException(); + } + + return await this.registrasiTesisService.findAllRegs({ + ...query, + page: query.page || 1, + idPenerima: + query.view === RoleEnum.S2_PEMBIMBING ? idPenerima : undefined, + }); + } + + @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.ADMIN, RoleEnum.S2_TIM_TESIS, RoleEnum.S2_PEMBIMBING) + @Patch("/:mhsId/interview") + async updateInterviewDateByMhsId( + @Param() params: UpdateByMhsParamsDto, + @Body() body: UpdateInterviewBodyDto, + @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; + } + + return await this.registrasiTesisService.updateInterviewDate( + params.mhsId, + body, + req, + 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, RoleEnum.S2_PEMBIMBING) + @Patch("/:mhsId/status") + async updateStatusByMhsId( + @Param() params: UpdateByMhsParamsDto, + @Body() body: UpdateStatusBodyDto, + @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; + } + + return await this.registrasiTesisService.updateStatus( + params.mhsId, + body, + req, + 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 updatePembimbingListByMhsId( + @Param() params: UpdateByMhsParamsDto, + @Body() body: UpdatePembimbingBodyDto, + @Req() req: Request, + ) { + return await this.registrasiTesisService.updatePembimbingList( + params.mhsId, + body, + req, ); } } diff --git a/src/registrasi-tesis/registrasi-tesis.dto.ts b/src/registrasi-tesis/registrasi-tesis.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d5d1bf284f8957d52f75af71505fad198e74b52 --- /dev/null +++ b/src/registrasi-tesis/registrasi-tesis.dto.ts @@ -0,0 +1,196 @@ +import { + IsDateString, + IsEnum, + IsNumberString, + IsOptional, + IsString, + IsUUID, +} from "@nestjs/class-validator"; +import { ApiProperty, ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { ArrayMinSize, ArrayUnique, IsArray } from "class-validator"; +import { + JalurEnum, + PendaftaranTesis, + RegStatus, +} from "src/entities/pendaftaranTesis.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; + +export class RegDto { + @IsUUID() + @ApiProperty() + idPenerima: string; + + @IsUUID() + @IsOptional() + @ApiPropertyOptional() + idTopik?: string; + + @IsEnum(JalurEnum) + @ApiProperty({ enum: JalurEnum }) + jalurPilihan: JalurEnum; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + judulTopik?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + deskripsiTopik?: string; +} + +export class RegByMhsParamDto { + @IsUUID() + @ApiProperty() + mahasiswaId: string; +} + +export class IdDto { + @IsUUID() + @ApiProperty() + id: string; +} + +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() + page?: number; + + @IsOptional() + @IsNumberString() + @ApiPropertyOptional() + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + search?: string; + + @IsOptional() + @IsEnum(RegStatus) + @ApiPropertyOptional({ enum: RegStatus }) + status?: RegStatus; + + @IsOptional() + @IsEnum(["nim"]) + @ApiPropertyOptional({ enum: ["nim"] }) + order_by?: "nim"; + + @IsOptional() + @IsEnum(["ASC", "DESC"]) + @ApiPropertyOptional({ enum: ["ASC", "DESC"] }) + sort?: "ASC" | "DESC"; +} + +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 { + @ApiProperty({ type: [FindAllNewestRegRespDataDto] }) + data: FindAllNewestRegRespDataDto[]; + + @ApiProperty() + count: number; +} + +export class RegStatisticsRespDataDto { + @ApiProperty() + amount: number; + @ApiProperty() + percentage: number; +} + +export class RegStatisticsRespDto { + @ApiProperty({ type: RegStatisticsRespDataDto }) + diterima: RegStatisticsRespDataDto; + + @ApiProperty({ type: RegStatisticsRespDataDto }) + sedang_proses: RegStatisticsRespDataDto; + + @ApiProperty({ type: RegStatisticsRespDataDto }) + ditolak: RegStatisticsRespDataDto; +} + +export class UpdateByMhsParamsDto { + @IsUUID() + @ApiProperty() + mhsId: string; +} + +export class UpdateInterviewBodyDto { + @ApiProperty({ type: Date }) + @IsDateString() + date: string; +} + +export class UpdateStatusBodyDto { + @ApiProperty({ enum: [RegStatus.APPROVED, RegStatus.REJECTED] }) + @IsEnum([RegStatus.APPROVED, RegStatus.REJECTED]) + status: RegStatus; +} + +export class UpdatePembimbingBodyDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID("all", { each: true }) + @ArrayMinSize(1) + @ArrayUnique() + pembimbing_ids: string[]; +} + +class DosenPembimbingDto extends PickType(Pengguna, [ + "id", + "nama", + "kontakWhatsApp", + "kontakMsTeams", + "kontakEmail", + "kontakTelp", + "kontakLainnya", +] 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.module.ts b/src/registrasi-tesis/registrasi-tesis.module.ts index 9b7a365ee001666b6b95db5168708a0b40bc9063..7c4c8a408b9f2d910ecb11cd35ec1182885bb787 100644 --- a/src/registrasi-tesis/registrasi-tesis.module.ts +++ b/src/registrasi-tesis/registrasi-tesis.module.ts @@ -1,22 +1,31 @@ import { Module } from "@nestjs/common"; -import { PengajuanPengambilanTopik } from "src/entities/pengajuanPengambilanTopik.entity"; +import { PendaftaranTesis } from "src/entities/pendaftaranTesis.entity"; import { Pengguna } from "src/entities/pengguna.entity"; import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; import { TypeOrmModule } from "@nestjs/typeorm"; import { RegistrasiTesisController } from "./registrasi-tesis.controller"; import { RegistrasiTesisService } from "./registrasi-tesis.service"; import { Topik } from "src/entities/topik.entity"; +import { CustomStrategy } from "src/middlewares/custom.strategy"; +import { AuthModule } from "src/auth/auth.module"; +import { PenggunaModule } from "src/pengguna/pengguna.module"; +import { PenggunaService } from "src/pengguna/pengguna.service"; +import { HttpModule } from "@nestjs/axios"; @Module({ imports: [ TypeOrmModule.forFeature([ Pengguna, DosenBimbingan, - PengajuanPengambilanTopik, + PendaftaranTesis, Topik, ]), + AuthModule, + PenggunaModule, + HttpModule, ], controllers: [RegistrasiTesisController], - providers: [RegistrasiTesisService], + providers: [RegistrasiTesisService, CustomStrategy, PenggunaService], + exports: [RegistrasiTesisService], }) export class RegistrasiTesisModule {} diff --git a/src/registrasi-tesis/registrasi-tesis.service.ts b/src/registrasi-tesis/registrasi-tesis.service.ts index 39ca645e009b0ff800338d6303308b9234b1c6a1..18135e28d03c83d6c97926c377218a43dce95180 100644 --- a/src/registrasi-tesis/registrasi-tesis.service.ts +++ b/src/registrasi-tesis/registrasi-tesis.service.ts @@ -1,76 +1,703 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { PengajuanPengambilanTopik } from "src/entities/pengajuanPengambilanTopik.entity"; -import { RegistrasiTopikDto } from "src/dto/registrasi-topik"; -import { Pengguna } from "src/entities/pengguna.entity"; -import { validateId } from "src/helper/validation"; +import * as dayjs from "dayjs"; +import { DosenBimbingan } from "src/entities/dosenBimbingan.entity"; +import { + PendaftaranTesis, + RegStatus, +} from "src/entities/pendaftaranTesis.entity"; +import { Pengguna, RoleEnum } from "src/entities/pengguna.entity"; import { Topik } from "src/entities/topik.entity"; +import { generateQueryBuilderOrderByObj } from "src/helper/sorting"; +import { ArrayContains, Brackets, DataSource, In, Repository } from "typeorm"; +import { + FindAllNewestRegRespDto, + IdDto, + RegDto, + RegStatisticsRespDto, + UpdateInterviewBodyDto, + UpdatePembimbingBodyDto, + UpdateStatusBodyDto, +} from "./registrasi-tesis.dto"; +import { PenggunaService } from "src/pengguna/pengguna.service"; +import { HttpService } from "@nestjs/axios"; +import { Request } from "express"; +import { firstValueFrom } from "rxjs"; @Injectable() export class RegistrasiTesisService { constructor( - @InjectRepository(PengajuanPengambilanTopik) - private pengajuanPengambilanTopikRepository: Repository<PengajuanPengambilanTopik>, + @InjectRepository(PendaftaranTesis) + private pendaftaranTesisRepository: Repository<PendaftaranTesis>, @InjectRepository(Pengguna) private penggunaRepository: Repository<Pengguna>, @InjectRepository(Topik) private topicRepostitory: Repository<Topik>, + @InjectRepository(DosenBimbingan) + private dosenBimbinganRepository: Repository<DosenBimbingan>, + private dataSource: DataSource, + private penggunaService: PenggunaService, + private httpService: HttpService, ) {} async createTopicRegistration( userId: string, - topicRegistrationDto: RegistrasiTopikDto, - ): Promise<PengajuanPengambilanTopik> { - // TODO: Proper validations - - // Validate id - validateId([ - { id: userId, object: "Pengguna" }, - { id: topicRegistrationDto.idPembimbing, object: "Pembimbing" }, - ]); - - // Validate user id, supervisor id - const [user, supervisor, topic] = await Promise.all([ - this.penggunaRepository.findOne({ - where: { id: userId }, + topicRegistrationDto: RegDto, + ): Promise<IdDto> { + const queries: ( + | Promise<void | PendaftaranTesis> + | Promise<Pengguna> + | Promise<Topik> + )[] = [ + this.getNewestRegByMhsOrFail(userId).catch((ex: BadRequestException) => { + if (ex.message === "No mahasiswa user with given id exists") { + throw ex; + } + // else: mahasiswa does not have pending registration -> allowed }), this.penggunaRepository.findOne({ - where: { id: topicRegistrationDto.idPembimbing }, + where: { id: topicRegistrationDto.idPenerima }, }), - this.topicRepostitory.findOne({ - where: { judul: topicRegistrationDto.judulTopik }, - }), - ]); + ]; + + if (topicRegistrationDto.idTopik) { + queries.push( + this.topicRepostitory.findOne({ + where: { id: topicRegistrationDto.idTopik }, + }), + ); + } + + const queryResult = await Promise.all(queries); + const lastPendaftaran = queryResult[0] as PendaftaranTesis; + const penerima = queryResult[1] as Pengguna; + let topik = topicRegistrationDto.idTopik ? (queryResult[2] as Topik) : null; + + if (!penerima) { + throw new NotFoundException("Penerima not found."); + } + + if (topicRegistrationDto.idTopik) { + if (!topik) { + throw new NotFoundException("Topic not found."); + } - if (!user) { - throw new NotFoundException("User not found."); - } else if (!supervisor) { - throw new NotFoundException("Supervisor not found."); - } else if (!topic) { - throw new NotFoundException("Topic not found."); + if (!topik.aktif) { + throw new BadRequestException("Topic is not active."); + } + } + + if (lastPendaftaran && lastPendaftaran.status !== RegStatus.REJECTED) { + throw new BadRequestException( + "Mahasiswa already has pending registration in this period", + ); + } + + if (!topik) { + if ( + !topicRegistrationDto.judulTopik || + !topicRegistrationDto.deskripsiTopik + ) { + throw new BadRequestException( + "Judul dan deskripsi topik tidak boleh kosong.", + ); + } + + topik = this.topicRepostitory.create({ + judul: topicRegistrationDto.judulTopik, + deskripsi: topicRegistrationDto.deskripsiTopik, + idPengaju: userId, + }); } // Create new registration - const createdRegistration = this.pengajuanPengambilanTopikRepository.create( - { - ...topicRegistrationDto, - waktuPengiriman: new Date(), - mahasiswa: user, - pembimbing: supervisor, - topik: topic, + const createdRegistration = this.pendaftaranTesisRepository.create({ + ...topicRegistrationDto, + mahasiswaId: userId, + penerima, + topik, + }); + + await this.pendaftaranTesisRepository.save(createdRegistration); + + return { + id: createdRegistration.id, + }; + } + + async findByUserId( + mahasiswaId: string, + isNewestOnly: boolean, + idPenerima?: string, + ) { + await this.penggunaService.isMahasiswaAktifOrFail(mahasiswaId); + + const baseQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pt") + .select([ + "pt.id", + "pt.jadwalInterview", + "pt.status", + "pt.jalurPilihan", + "pt.waktuPengiriman", + "topik.judul", + "topik.deskripsi", + "penerima.id", + "penerima.nama", + "penerima.kontakWhatsApp", + "penerima.kontakMsTeams", + "penerima.kontakEmail", + "penerima.kontakTelp", + "penerima.kontakLainnya", + "dosenBimbingan", + "dosen.id", + "dosen.nama", + "dosen.kontakWhatsApp", + "dosen.kontakMsTeams", + "dosen.kontakEmail", + "dosen.kontakTelp", + "dosen.kontakLainnya", + ]) + .leftJoin("pt.topik", "topik") + .leftJoin("pt.penerima", "penerima") + .leftJoin("pt.dosenBimbingan", "dosenBimbingan") + .leftJoin("dosenBimbingan.dosen", "dosen") + .where("pt.mahasiswaId = :mahasiswaId", { mahasiswaId }) + .orderBy("pt.waktuPengiriman", "DESC"); + + const res = await baseQuery.getMany(); + + if (res.length === 0) { + throw new NotFoundException("Tidak ada registrasi tesis yang ditemukan."); + } + + 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: { + idPenerima?: string; + }): Promise<RegStatisticsRespDto> { + let totalMahasiswa = this.penggunaRepository.count({ + where: { roles: ArrayContains([RoleEnum.S2_MAHASISWA]) }, + }); + + // 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", + ) + .innerJoin("pt.mahasiswa", "mahasiswa") + .where("mahasiswa.aktif = true"); + + if (options.idPenerima) { + baseQuery.andWhere("pt.penerimaId = :idPenerima", { + idPenerima: options.idPenerima, + }); + + totalMahasiswa = baseQuery.getCount(); + } + + 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), + }, + }; + } + + async findAllRegs(options: { + status?: RegStatus; + page: number; + limit?: number; + idPenerima?: string; + search?: string; + order_by?: "nim"; + sort?: "ASC" | "DESC"; + }) { + const baseQuery = this.pendaftaranTesisRepository + .createQueryBuilder("pt") + .select("pt"); + + // Show newest regs per Mhs + // May need to make materialized view to improve performance + 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.penerima", "penerima") + .innerJoinAndSelect("pt.mahasiswa", "mahasiswa") + .where("mahasiswa.aktif = true"); + + if (options.idPenerima) { + baseQuery.andWhere("pt.penerimaId = :idPenerima", { + idPenerima: options.idPenerima, + }); + } + + if (options.search) { + baseQuery.andWhere( + new Brackets((qb) => + qb + .where("mahasiswa.nama ILIKE :search", { + search: `%${options.search}%`, + }) + .orWhere("mahasiswa.nim ILIKE :search", { + search: `%${options.search}%`, + }), + ), + ); + } + + if (options.status) { + baseQuery.andWhere("pt.status = :status", { status: options.status }); + } + + if (options.order_by) { + const orderByMapping = { + nim: "CAST(mahasiswa.nim AS INTEGER)", + }; + + baseQuery.orderBy( + generateQueryBuilderOrderByObj( + orderByMapping, + options.order_by, + options.sort, + ), + ); + } + + if (options.limit) { + baseQuery.take(options.limit); + baseQuery.skip((options.page - 1) * options.limit); + } + + const [data, count] = await baseQuery.getManyAndCount(); + + const resData: FindAllNewestRegRespDto = { + data: data.map((reg) => ({ + pendaftaran_id: reg.id, + nim: reg.mahasiswa.nim, + mahasiswa_id: reg.mahasiswa.id, + mahasiswa_nama: reg.mahasiswa.nama, + pembimbing_nama: reg.penerima.nama, + status: reg.status, + jadwal_interview: reg.jadwalInterview, + })), + count, + }; + + return resData; + } + + async getNewestRegByMhsOrFail(mahasiswaId: string) { + const mahasiswa = await this.penggunaRepository.findOne({ + select: { + id: true, + roles: true, }, + where: { + id: mahasiswaId, + aktif: true, + }, + }); + + if (!mahasiswa || !mahasiswa.roles.includes(RoleEnum.S2_MAHASISWA)) + throw new BadRequestException( + "No active mahasiswa user with given id exists", + ); + + const newestReg = await this.pendaftaranTesisRepository.findOne({ + select: { + id: true, + jadwalInterview: true, + status: true, + waktuPengiriman: true, + jalurPilihan: true, + topik: { + judul: true, + deskripsi: true, + }, + penerima: { + id: true, + }, + }, + relations: { + topik: true, + penerima: true, + mahasiswa: true, + }, + where: { + mahasiswa: mahasiswa, + }, + order: { + waktuPengiriman: "DESC", + }, + }); + + if (!newestReg) + throw new BadRequestException( + "Mahasiswa does not have pending registration in this period", + ); + + return newestReg; + } + + async updateInterviewDate( + mahasiswaId: string, + dto: UpdateInterviewBodyDto, + req: Request, + idPenerima?: string, + ) { + await this.penggunaService.isMahasiswaAktifOrFail(mahasiswaId); + + 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.getNewestRegByMhsOrFail(mahasiswaId); + + if (newestReg && idPenerima && newestReg.penerima.id !== idPenerima) { + throw new ForbiddenException(); + } + + const restrictedStatus: RegStatus[] = [ + RegStatus.APPROVED, + RegStatus.REJECTED, + ]; + + if (restrictedStatus.includes(newestReg.status)) + throw new BadRequestException( + newestReg.status == RegStatus.APPROVED + ? "Cannot set interview for registration that is already accepted" + : "Mahasiswa does not have pending registration", + ); + + const newDate = new Date(dto.date); + + let token = ""; + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + const { data: notif } = await firstValueFrom( + this.httpService.post( + `${process.env.AUTH_SERVICE_URL}/notifikasi`, + { + title: "Jadwal interview pendaftaran Anda telah diubah", + description: + "Jadwal interview pendaftaran tesis Anda telah diubah. Silahkan periksa kembali data Anda untuk mengetahui lebih lanjut.", + penggunaId: mahasiswaId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), ); - await this.pengajuanPengambilanTopikRepository.save(createdRegistration); + try { + await this.pendaftaranTesisRepository.update( + { id: newestReg.id }, + { jadwalInterview: newDate, status: RegStatus.INTERVIEW }, + ); + } catch { + await firstValueFrom( + this.httpService.delete( + `${process.env.AUTH_SERVICE_URL}/notifikasi/${notif.id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + } - return createdRegistration; + return { id: newestReg.id } as IdDto; } - async findByUserId(mahasiswaId: string) { - return await this.pengajuanPengambilanTopikRepository.find({ - relations: ["topik", "pembimbing"], - where: { mahasiswa: { id: mahasiswaId } }, + async updateStatus( + mahasiswaId: string, + dto: UpdateStatusBodyDto, + req: Request, + idPenerima?: string, + ) { + await this.penggunaService.isMahasiswaAktifOrFail(mahasiswaId); + + const newestReg = await this.getNewestRegByMhsOrFail(mahasiswaId); + + if (newestReg && idPenerima && newestReg.penerima.id !== idPenerima) { + throw new ForbiddenException(); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + let token = ""; + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + const { data: notif } = await firstValueFrom( + this.httpService.post( + `${process.env.AUTH_SERVICE_URL}/notifikasi`, + { + title: `Pendaftaran tesis Anda ${dto.status === RegStatus.APPROVED ? "diterima" : "ditolak"}`, + description: `Pendaftaran tesis Anda ${dto.status === RegStatus.APPROVED ? "diterima" : "ditolak"}. Silahkan periksa kembali data Anda untuk mengetahui lebih lanjut.`, + penggunaId: mahasiswaId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + 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, + }); + } + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + await firstValueFrom( + this.httpService.delete( + `${process.env.AUTH_SERVICE_URL}/notifikasi/${notif.id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + + return { id: newestReg.id } as IdDto; + } + + async updatePembimbingList( + mahasiswaId: string, + { pembimbing_ids: dosen_ids }: UpdatePembimbingBodyDto, + req: Request, + ) { + await this.penggunaService.isMahasiswaAktifOrFail(mahasiswaId); + + const newestReg = await this.getNewestRegByMhsOrFail(mahasiswaId); + + if (newestReg.status !== RegStatus.APPROVED) + throw new BadRequestException( + "Cannot update pembimbing on non-approved registration", + ); + + const newPembimbingList = await this.penggunaRepository.findBy({ + id: In(dosen_ids), }); + + if ( + newPembimbingList.length !== dosen_ids.length || + newPembimbingList.some( + (dosen) => !dosen.roles.includes(RoleEnum.S2_PEMBIMBING), + ) + ) + throw new BadRequestException("Dosen id list contains invalid user ids"); + + const currentPembimbing = await this.dosenBimbinganRepository.findBy({ + idPendaftaran: newestReg.id, + }); + + const newPembimbingIds = newPembimbingList.map((dosen) => dosen.id); + const currentPembimbingIds = currentPembimbing.map( + (currentPembimbing) => currentPembimbing.idDosen, + ); + + const idsToBeAdded = newPembimbingIds.filter( + (newId) => !currentPembimbingIds.includes(newId), + ); + + const idsToBeDeleted = currentPembimbingIds.filter( + (newId) => !newPembimbingIds.includes(newId), + ); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + let token = ""; + if (req?.cookies?.[process.env.COOKIE_NAME]) { + token = req.cookies[process.env.COOKIE_NAME]; + } + if (req.headers?.authorization) { + token = req.headers.authorization.slice(7); + } + + const { data: notif } = await firstValueFrom( + this.httpService.post( + `${process.env.AUTH_SERVICE_URL}/notifikasi`, + { + title: "Dosen pembimbing Anda telah diubah", + description: + "Dosen pembimbing Anda telah diubah. Silahkan periksa kembali data Anda untuk mengetahui lebih lanjut.", + penggunaId: mahasiswaId, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + try { + await queryRunner.manager.insert( + DosenBimbingan, + idsToBeAdded.map((idDosen) => ({ pendaftaran: newestReg, idDosen })), + ); + await queryRunner.manager.delete(DosenBimbingan, { + idDosen: In(idsToBeDeleted), + }); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + + await firstValueFrom( + this.httpService.delete( + `${process.env.AUTH_SERVICE_URL}/notifikasi/${notif.id}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + + return { id: newestReg.id } as IdDto; } }