From 76ae96c0577530923b70cb1f521da21792386297 Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Tue, 23 Jun 2026 13:38:50 +0700 Subject: [PATCH 01/13] chore: setup app, dependencies, seeder, README --- README.md | 188 ++++++ app.js | 28 +- package-lock.json | 1512 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 8 +- scripts/seed.js | 222 +++++++ 5 files changed, 1906 insertions(+), 52 deletions(-) create mode 100644 README.md create mode 100644 scripts/seed.js diff --git a/README.md b/README.md new file mode 100644 index 00000000..2bf0a7be --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# FacultyWare โ€” Survey Module (Kelompok A13) + +Sistem pengisian survei internal Fakultas Teknologi Informasi (FTI). Aplikasi ini ditujukan khusus untuk civitas akademika yang telah terdaftar di sistem untuk memberikan umpan balik (feedback) secara internal, dan dilengkapi dengan panel admin untuk rekapitulasi, statistik, cetak PDF, ekspor CSV, serta REST API. + +--- + +## Spesifikasi Teknologi +- **Backend**: Express.js (Node.js) +- **Database**: MySQL dengan `mysql2` (Raw Query, tanpa ORM) +- **Frontend / Styling**: Basecoat UI & Tailwind CSS +- **Interaktivitas**: HTMX & Vanilla JavaScript +- **Session & Auth**: `express-session` & `bcryptjs` untuk hash password +- **Generate PDF**: `pdfkit` untuk export laporan PDF langsung download + +--- + +## Pembagian Tugas & Detail Fitur + +### ๐Ÿ‘ฉโ€๐Ÿ’ป Fitur Arifah (6 Fitur Utama & API) + +1. **Lihat Daftar Survey (Respondent)** + - **Endpoint**: `GET /survey` + - **Deskripsi**: Menampilkan semua survei aktif (`is_active = 1`). Dilengkapi dengan pencarian judul/deskripsi, pagination (10 data per halaman), serta indikator badge "Sudah Diisi" jika responden telah mengisi survei tersebut. + - **ACL**: Hanya untuk role `respondent`. + +2. **Validasi PIN + Isi + Submit Survey (Respondent)** + - **Endpoint**: + - `GET /survey/:id` (Form Input PIN) + - `POST /survey/:id/pin` (Validasi PIN) + - `GET /survey/:id/fill` (Form Kuesioner) + - `POST /survey/:id/submit` (Submit Jawaban) + - **Deskripsi**: Memvalidasi PIN unik (harus sesuai dengan survei, email user, dan belum pernah digunakan). Mendukung 3 tipe pertanyaan: *single choice* (radio button), *multiple choice* (checkbox), dan *short answer* (textarea). Menyediakan validasi server dan validasi client-side untuk memastikan semua pertanyaan wajib diisi. + - **ACL**: Hanya untuk role `respondent`. + +3. **Rekap Detail Jawaban Per Responden (Admin)** + - **Endpoint**: `GET /admin/survey/rekap` & `GET /admin/survey/rekap/:id` + - **Deskripsi**: Halaman untuk melihat detail nama, email, dan waktu pengisian dari responden yang telah mengisi survei tertentu. Dilengkapi fitur pencarian nama/email dan pagination. + - **ACL**: Hanya untuk role `admin`. + +4. **Statistik Jawaban Per Pertanyaan (Admin)** + - **Endpoint**: `GET /admin/survey/statistik` & `GET /admin/survey/statistik/:id` + - **Deskripsi**: Menampilkan persentase jawaban per pertanyaan dalam bentuk visual *progress bar* (untuk pilihan ganda) dan daftar teks (untuk jawaban esai/singkat). Halaman list dilengkapi pencarian dan pagination. + - **ACL**: Hanya untuk role `admin`. + +5. **Export CSV Data Mentah (Admin)** + - **Endpoint**: `GET /admin/survey/export/:id` + - **Deskripsi**: Mengunduh data respons mentah ke dalam format CSV dengan BOM UTF-8 (agar format karakter bahasa Indonesia terbaca dengan benar di Excel). Jika data kosong, sistem memberikan pesan error informatif di client. + - **ACL**: Hanya untuk role `admin`. + +6. **REST API GET List Survey + Pertanyaan (REST API)** + - **Endpoint**: `GET /api/surveys` + - **Deskripsi**: Menghasilkan respon JSON berisi daftar survei aktif lengkap beserta pertanyaan dan pilihan jawabannya. + - **ACL**: Butuh login (`isAuthenticated`), dapat diakses admin & respondent. + +--- + +### ๐Ÿ‘ฉโ€๐Ÿ’ป Fitur Aqila (6 Fitur Utama & API) + +1. **Dashboard Survey (Admin)** + - **Endpoint**: `GET /admin/responden` + - **Deskripsi**: Menampilkan daftar seluruh survei di database beserta jumlah responden dan status keaktifan survei. Dilengkapi fitur pencarian, pagination, dan tombol aksi cepat (Responden, Riwayat, PDF, Toggle Aktif). + - **ACL**: Hanya untuk role `admin`. + +2. **Daftar Responden Per Survey (Admin)** + - **Endpoint**: `GET /admin/responden/:id` + - **Deskripsi**: Menampilkan informasi detail nama, email, nomor HP, dan waktu pengisian khusus untuk survei yang dipilih. Dilengkapi fitur pencarian dan pagination. + - **ACL**: Hanya untuk role `admin`. + +3. **Toggle Aktif/Nonaktif Survey (Admin)** + - **Endpoint**: `POST /admin/responden/:id/toggle` + - **Deskripsi**: Mengubah status keaktifan survei (`is_active` antara 0 atau 1) dengan aman menggunakan method POST. + - **ACL**: Hanya untuk role `admin`. + +4. **Riwayat Pengisian Per Survey (Admin)** + - **Endpoint**: `GET /admin/responden/:id/riwayat` + - **Deskripsi**: Menampilkan log riwayat pengisian berupa nama, email, jumlah pertanyaan yang dijawab, serta waktu pengisian. + - **ACL**: Hanya untuk role `admin`. + +5. **Export PDF Laporan Ringkasan (Admin)** + - **Endpoint**: `GET /admin/responden/:id/pdf` + - **Deskripsi**: Menghasilkan file PDF laporan ringkasan yang langsung dapat didownload menggunakan `pdfkit`. Berisi judul survey, total responden, statistik jawaban per pertanyaan (tabel untuk pilihan ganda, list untuk jawaban teks). Jika survei belum memiliki responden, akan diredirect kembali ke dashboard dengan pesan error informatif. + - **ACL**: Hanya untuk role `admin`. + +6. **REST API GET Rekap Jawaban Per Survey (REST API)** + - **Endpoint**: `GET /api/surveys/:id/rekap` + - **Deskripsi**: Menghasilkan respon JSON berisi ringkasan data kuesioner, jumlah responden, daftar pertanyaan, serta persentase pilihan jawaban. + - **ACL**: Butuh login (`isAuthenticated`). + +--- + +## Alur Sistem + +``` +RESPONDENT (Mahasiswa/Civitas): +Login โ”€โ”€> Lihat Daftar Survey Aktif โ”€โ”€> Pilih Survey โ”€โ”€> Masukkan PIN โ”€โ”€> Validasi PIN โ”€โ”€> Isi Kuesioner โ”€โ”€> Submit + +ADMIN (Staf/Fakultas): +Login โ”€โ”€> Kelola Dashboard Survey (Toggle Aktif) โ”€โ”€> Lihat Rekap / Statistik / Riwayat โ”€โ”€> Export CSV / Download PDF +``` + +--- + +## Struktur Project + +``` +facultyware/ +โ”œโ”€โ”€ app.js # Konfigurasi Express, middleware, session, dan router +โ”œโ”€โ”€ .env # Konfigurasi database & session secret +โ”œโ”€โ”€ lib/ +โ”‚ โ””โ”€โ”€ db.js # Koneksi database MySQL Pool menggunakan mysql2 +โ”œโ”€โ”€ middlewares/ +โ”‚ โ”œโ”€โ”€ auth.js # Middleware otentikasi login (isAuthenticated) +โ”‚ โ”œโ”€โ”€ acl.js # Middleware RBAC (checkRole & checkPermission) +โ”‚ โ””โ”€โ”€ error.js # Handler error terpusat (404 & 500) +โ”œโ”€โ”€ controllers/ +โ”‚ โ”œโ”€โ”€ indexController.js # Controller login, logout, dan beranda +โ”‚ โ”œโ”€โ”€ surveyController.js # Controller fitur Arifah (6 fitur) +โ”‚ โ””โ”€โ”€ adminRespondenController.js # Controller fitur Aqila (6 fitur) +โ”œโ”€โ”€ routes/ +โ”‚ โ”œโ”€โ”€ index.js # Router login, logout, dan dashboard utama +โ”‚ โ”œโ”€โ”€ users.js # Router user management +โ”‚ โ”œโ”€โ”€ survey.js # Router modul survei responden +โ”‚ โ”œโ”€โ”€ adminSurvey.js # Router modul rekap admin (Arifah) +โ”‚ โ”œโ”€โ”€ adminResponden.js # Router modul responden admin (Aqila) +โ”‚ โ””โ”€โ”€ api.js # Router endpoint REST API +โ”œโ”€โ”€ views/ +โ”‚ โ”œโ”€โ”€ login.ejs # Template halaman login +โ”‚ โ”œโ”€โ”€ home.ejs # Layout utama admin & responden (Basecoat UI) +โ”‚ โ”œโ”€โ”€ index.ejs # Template index +โ”‚ โ””โ”€โ”€ error.ejs # Template error page +โ””โ”€โ”€ scripts/ + โ”œโ”€โ”€ init_db.js # Inisialisasi awal tabel user (dari dosen) + โ””โ”€โ”€ seed.js # Seeder skema database & data dummy kuesioner lengkap +``` + +--- + +## Cara Instalasi & Menjalankan Project + +### 1. Prasyarat +Pastikan Anda sudah menginstal: +- Node.js (v16 atau lebih baru) +- MySQL / MariaDB Server + +### 2. Kloning dan Instal Dependensi +Masuk ke direktori project, lalu jalankan perintah: +```bash +npm install +``` + +### 3. Konfigurasi Database (`.env`) +Buat atau edit file `.env` di root direktori project dan isi konfigurasinya: +```env +DB_HOST=localhost +DB_USER=root +DB_PASSWORD= +DB_NAME=facultyware +SESSION_SECRET=rahasia123 +``` + +### 4. Buat Database dan Migrasi Data +1. Buat database kosong bernama `facultyware` di MySQL Anda. +2. Jalankan script seeder untuk membuat semua tabel (termasuk tabel ACL, kuesioner, unit organisasi) serta memasukkan data dummy lengkap: +```bash +node scripts/seed.js +``` + +### 5. Jalankan Server +Jalankan server dalam mode development menggunakan perintah: +```bash +npm run dev +``` +Aplikasi akan berjalan di `http://localhost:3000` (atau port default yang ditentukan di `bin/www`). + +--- + +## Akun Demo & PIN Survei (Dummy Data) + +Gunakan kredensial berikut untuk menguji sistem: + +| Peran (Role) | Email Login | Password | Keterangan | +| :--- | :--- | :--- | :--- | +| **Admin** | `admin@fti.ac.id` | `admin123` | Akses penuh ke dashboard admin | +| **Respondent** | `rifaqila@fti.ac.id` | `user123` | Mengisi kuesioner | + +### PIN Kuesioner untuk `rifaqila@fti.ac.id`: +- **Survey Kepuasan Layanan Akademik**: `PIN001` +- **Survey Evaluasi Fasilitas Kampus**: `PIN002` \ No newline at end of file diff --git a/app.js b/app.js index f91917a2..379f78bc 100644 --- a/app.js +++ b/app.js @@ -4,15 +4,18 @@ var path = require('path'); var cookieParser = require('cookie-parser'); var logger = require('morgan'); var session = require('express-session'); -var MySQLStore = require('express-mysql-session')(session); var indexRouter = require('./routes/index'); var usersRouter = require('./routes/users'); +var surveyRouter = require('./routes/survey'); +var adminSurveyRouter = require('./routes/adminSurvey'); +var adminRespondenRouter = require('./routes/adminResponden'); +var apiRouter = require('./routes/api'); + const { notFoundHandler, errorHandler } = require('./middlewares/error'); var app = express(); -// view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); @@ -22,32 +25,23 @@ app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); -// Session configuration -const sessionStore = new MySQLStore({ - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, -}); - app.use(session({ - key: 'session_cookie_name', secret: process.env.SESSION_SECRET || 'secret', - store: sessionStore, resave: false, saveUninitialized: false, cookie: { - maxAge: 1000 * 60 * 60 * 24 // 1 day + maxAge: 1000 * 60 * 60 * 24 } })); app.use('/', indexRouter); app.use('/users', usersRouter); +app.use('/survey', surveyRouter); +app.use('/admin/survey', adminSurveyRouter); +app.use('/admin/responden', adminRespondenRouter); +app.use('/api', apiRouter); -// catch 404 and forward to error handler app.use(notFoundHandler); - -// error handler app.use(errorHandler); -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 59b6794c..7ae543f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,20 @@ "express-session": "^1.19.0", "http-errors": "~1.6.3", "morgan": "~1.9.1", - "mysql2": "^3.22.3" + "mysql2": "^3.22.3", + "pdfkit": "^0.15.0" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } + }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@types/node": { @@ -44,12 +57,57 @@ "node": ">= 0.6" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -59,6 +117,36 @@ "node": ">= 6.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -80,6 +168,19 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "1.18.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", @@ -101,6 +202,50 @@ "node": ">= 0.8" } }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -110,6 +255,87 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", @@ -156,6 +382,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -165,6 +397,72 @@ "ms": "2.0.0" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -189,6 +487,12 @@ "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==", "license": "MIT" }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -201,6 +505,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -225,6 +543,56 @@ "node": ">= 0.8" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -421,6 +789,19 @@ "node": ">= 0.6" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", @@ -439,6 +820,38 @@ "node": ">= 0.8" } }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -457,6 +870,39 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -466,56 +912,516 @@ "is-property": "^1.0.2" } }, - "node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/iconv-lite": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", - "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "license": "ISC" + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/long": { - "version": "5.3.2", + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/long": { + "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" @@ -544,6 +1450,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -598,6 +1513,22 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/morgan": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", @@ -679,6 +1610,127 @@ "node": ">= 0.6" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -700,6 +1752,12 @@ "node": ">= 0.8" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -715,6 +1773,49 @@ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", "license": "MIT" }, + "node_modules/pdfkit": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", + "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -728,6 +1829,13 @@ "node": ">= 0.10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -770,18 +1878,87 @@ "node": ">= 0.8" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", @@ -826,12 +2003,129 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", "license": "ISC" }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sql-escaper": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", @@ -865,6 +2159,67 @@ "node": ">= 0.6" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -890,6 +2245,13 @@ "node": ">= 0.8" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -897,6 +2259,32 @@ "license": "MIT", "peer": true }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -923,6 +2311,64 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } } } } diff --git a/package.json b/package.json index bf3659a5..00d26d30 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "express-session": "^1.19.0", "http-errors": "~1.6.3", "morgan": "~1.9.1", - "mysql2": "^3.22.3" + "mysql2": "^3.22.3", + "pdfkit": "^0.15.0" + }, + "devDependencies": { + "nodemon": "^3.1.14" } -} +} \ No newline at end of file diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100644 index 00000000..6b2c2817 --- /dev/null +++ b/scripts/seed.js @@ -0,0 +1,222 @@ +const db = require('../lib/db'); +const bcrypt = require('bcryptjs'); + +async function seed() { + try { + console.log('Seeding data...'); + + const adminPassword = await bcrypt.hash('admin123', 10); + const userPassword = await bcrypt.hash('user123', 10); + + // ============================================================ + // 1. USERS + // Kenapa users dulu: employees.id FK ke users.id + // Password di-hash bcrypt karena indexController pakai bcrypt.compare + // Kalau plain text, login tidak akan pernah berhasil + // ============================================================ + await db.query(` + INSERT INTO users (id, name, email, password, created_at, updated_at) VALUES + (1, 'Admin FTI', 'admin@fti.ac.id', ?, NOW(), NOW()), + (2, 'Rifaqila', 'rifaqila@fti.ac.id', ?, NOW(), NOW()) + `, [adminPassword, userPassword]); + console.log('โœ… Users inserted'); + + // ============================================================ + // 2. ORGANIZATION UNITS + // Kenapa perlu: employees.organization_unit_id FK ke sini + // organization_unit_id = 1 karena self-reference ke dirinya sendiri + // unit paling atas tidak punya parent, merujuk ke dirinya sendiri + // ============================================================ + await db.query(` + INSERT INTO organization_units (id, name, code, parent_id, type, organization_unit_id, created_at, updated_at) VALUES + (1, 'Fakultas Teknologi Informasi', 'FTI', NULL, 'faculty', 1, NOW(), NOW()) + `); + console.log('โœ… Organization units inserted'); + + // ============================================================ + // 3. EMPLOYMENT STATUSES + // Kenapa perlu: employees.employment_status_id FK ke sini + // ============================================================ + await db.query(` + INSERT INTO employment_statuses (id, name, description, created_at, updated_at) VALUES + (1, 'Tetap', 'Pegawai Tetap', NOW(), NOW()) + `); + console.log('โœ… Employment statuses inserted'); + + // ============================================================ + // 4. EMPLOYEES + // Kenapa perlu: surveys.created_by FK ke employees.id + // Kenapa id = 1: employees.id FK ke users.id + // Jadi id employee harus sama dengan id user yang bersangkutan + // Hanya insert admin (id=1) karena hanya admin yang buat survey + // ============================================================ + await db.query(` + INSERT INTO employees (id, employee_number, name, birth_place, birth_date, gender, marital_status, address, organization_unit_id, hire_date, employment_status_id, status, created_at, updated_at) VALUES + (1, 'EMP001', 'Admin FTI', 'Jakarta', '1980-01-01', 'male', 'married', 'Jl. Kampus No.1', 1, '2010-01-01', 1, 'active', NOW(), NOW()) + `); + console.log('โœ… Employees inserted'); + + // ============================================================ + // 5. ROLES + // Kenapa 2 role: admin untuk kelola hasil, respondent untuk isi survey + // guard_name 'web' konvensi Spatie Permission yang dipakai DB ini + // ============================================================ + await db.query(` + INSERT INTO roles (id, name, guard_name, created_at, updated_at) VALUES + (1, 'admin', 'web', NOW(), NOW()), + (2, 'respondent', 'web', NOW(), NOW()) + `); + console.log('โœ… Roles inserted'); + + // ============================================================ + // 6. PERMISSIONS + // Kenapa perlu: role_has_permissions.permission_id FK ke sini + // Permission dibuat per fitur supaya ACL granular + // survey.view โ†’ lihat daftar survey + // survey.fill โ†’ isi survey + // survey.admin โ†’ akses halaman admin + // survey.export โ†’ download CSV + // ============================================================ + await db.query(` + INSERT INTO permissions (id, name, guard_name, created_at, updated_at) VALUES + (1, 'survey.view', 'web', NOW(), NOW()), + (2, 'survey.fill', 'web', NOW(), NOW()), + (3, 'survey.admin', 'web', NOW(), NOW()), + (4, 'survey.export', 'web', NOW(), NOW()) + `); + console.log('โœ… Permissions inserted'); + + // ============================================================ + // 7. ROLE HAS PERMISSIONS + // admin dapat semua permission (1,2,3,4) + // respondent hanya bisa view dan fill (1,2) + // ============================================================ + await db.query(` + INSERT INTO role_has_permissions (permission_id, role_id) VALUES + (1, 1), (2, 1), (3, 1), (4, 1), + (1, 2), (2, 2) + `); + console.log('โœ… Role permissions assigned'); + + // ============================================================ + // 8. MODEL HAS ROLES + // Admin FTI (1) = admin, Rifaqila (2) = respondent + // model_type 'App\\Models\\User' konvensi Spatie Permission + // model_id adalah id user yang dapat role tersebut + // ============================================================ + await db.query(` + INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES + (1, 'App\\\\Models\\\\User', 1), + (2, 'App\\\\Models\\\\User', 2) + `); + console.log('โœ… Roles assigned to users'); + + // ============================================================ + // 9. SURVEYS + // is_active = 1 sudah dipublikasikan, tampil di list respondent + // is_active = 0 draft, tidak tampil untuk test filter + // created_by dan employee_id = 1 karena admin yang buat survey + // ๐Ÿ’ก TIPS TAMBAH/HAPUS SURVEY DUMMY: + // Tambah baris baru di dalam tanda kurung bawah ini untuk menambahkan survey default baru, + // atau hapus baris yang ada jika ingin menghapus survey tertentu. + // ============================================================ + await db.query(` + INSERT INTO surveys (id, title, description, start_date, end_date, is_active, created_by, employee_id, created_at, updated_at) VALUES + (1, 'Survey Kepuasan Layanan Akademik', 'Survey untuk mengukur tingkat kepuasan mahasiswa terhadap layanan akademik FTI', '2026-01-01', '2026-12-31', 1, 1, 1, NOW(), NOW()), + (2, 'Survey Evaluasi Fasilitas Kampus', 'Survey untuk mengevaluasi kondisi dan kelengkapan fasilitas kampus', '2026-01-01', '2026-12-31', 1, 1, 1, NOW(), NOW()), + (3, 'Survey Draft Belum Aktif', 'Survey ini belum dipublikasikan', '2026-01-01', '2026-12-31', 0, 1, 1, NOW(), NOW()) + `); + console.log('โœ… Surveys inserted'); + + // ============================================================ + // 10. SURVEY QUESTIONS + // 3 tipe: single_choice, multiple_choice, short_answer + // Semua tipe ada supaya bisa test rendering semua tipe di form + // ๐Ÿ’ก TIPS TAMBAH/HAPUS PERTANYAAN DUMMY: + // Tambah atau hapus baris di kueri INSERT di bawah ini untuk mengedit bank pertanyaan kuesioner. + // Tipe pertanyaan yang didukung: 'single_choice' (pilihan ganda), 'multiple_choice' (checkbox), atau 'short_answer' (esai). + // ============================================================ + await db.query(` + INSERT INTO survey_questions (id, question_text, type, is_active, created_at, updated_at) VALUES + (1, 'Bagaimana penilaian Anda terhadap kecepatan pelayanan akademik?', 'single_choice', 1, NOW(), NOW()), + (2, 'Fasilitas apa saja yang menurut Anda perlu ditingkatkan?', 'multiple_choice', 1, NOW(), NOW()), + (3, 'Berikan saran Anda untuk peningkatan layanan akademik.', 'short_answer', 1, NOW(), NOW()), + (4, 'Bagaimana penilaian Anda terhadap kebersihan kampus?', 'single_choice', 1, NOW(), NOW()), + (5, 'Layanan digital apa yang Anda gunakan paling sering?', 'multiple_choice', 1, NOW(), NOW()) + `); + console.log('โœ… Questions inserted'); + + // ============================================================ + // 11. SURVEY QUESTION OPTIONS + // Hanya untuk single_choice dan multiple_choice + // short_answer tidak punya opsi karena jawabannya bebas diketik + // weight dipakai untuk hitung skor di fitur statistik nanti + // ============================================================ + await db.query(` + INSERT INTO survey_question_options (id, survey_question_id, option_text, weight, created_at, updated_at) VALUES + (1, 1, 'Sangat Baik', 4, NOW(), NOW()), + (2, 1, 'Baik', 3, NOW(), NOW()), + (3, 1, 'Cukup', 2, NOW(), NOW()), + (4, 1, 'Kurang', 1, NOW(), NOW()), + (5, 2, 'Perpustakaan', 1, NOW(), NOW()), + (6, 2, 'Laboratorium', 1, NOW(), NOW()), + (7, 2, 'Ruang Kelas', 1, NOW(), NOW()), + (8, 2, 'Kantin', 1, NOW(), NOW()), + (9, 4, 'Sangat Bersih', 4, NOW(), NOW()), + (10, 4, 'Bersih', 3, NOW(), NOW()), + (11, 4, 'Cukup Bersih', 2, NOW(), NOW()), + (12, 4, 'Kotor', 1, NOW(), NOW()), + (13, 5, 'SIAKAD', 1, NOW(), NOW()), + (14, 5, 'E-Learning', 1, NOW(), NOW()), + (15, 5, 'Portal Mahasiswa', 1, NOW(), NOW()) + `); + console.log('โœ… Question options inserted'); + + // ============================================================ + // 12. SURVEY QUESTION ASSIGNMENTS + // Mapping pertanyaan ke survey + // Satu pertanyaan bisa dipakai di banyak survey (reusable) + // order menentukan urutan tampil pertanyaan di form + // ============================================================ + await db.query(` + INSERT INTO survey_question_assignments (survey_id, survey_question_id, \`order\`, created_at, updated_at) VALUES + (1, 1, 1, NOW(), NOW()), + (1, 2, 2, NOW(), NOW()), + (1, 3, 3, NOW(), NOW()), + (2, 4, 1, NOW(), NOW()), + (2, 5, 2, NOW(), NOW()) + `); + console.log('โœ… Question assignments inserted'); + + // ============================================================ + // 13. SURVEY INVITATIONS + PIN + // PIN unik per undangan per survey + // is_used = 0 PIN belum dipakai + // Setelah submit survey, is_used di-update jadi 1 + // Hanya Rifaqila yang punya undangan karena dia satu-satunya + // respondent di sistem dummy kita + // ============================================================ + await db.query(` + INSERT INTO survey_invitations (id, survey_id, name, email, phone, pin, is_used, used_at, created_at, updated_at) VALUES + (1, 1, 'Rifaqila', 'rifaqila@fti.ac.id', '08123456789', 'PIN001', 0, NULL, NOW(), NOW()), + (2, 2, 'Rifaqila', 'rifaqila@fti.ac.id', '08123456789', 'PIN002', 0, NULL, NOW(), NOW()) + `); + console.log('โœ… Invitations inserted'); + + console.log(''); + console.log('๐ŸŽ‰ Seeding selesai!'); + console.log('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + console.log('Login admin โ†’ admin@fti.ac.id | admin123'); + console.log('Login respondent โ†’ rifaqila@fti.ac.id | user123'); + console.log('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + console.log('PIN survey 1 โ†’ PIN001'); + console.log('PIN survey 2 โ†’ PIN002'); + process.exit(0); + + } catch (err) { + console.error('โŒ Seeding gagal:', err.message); + process.exit(1); + } +} + +seed(); \ No newline at end of file From 5d5a0a7edf8ded03b8f24f327e50642bc865372a Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Tue, 23 Jun 2026 13:40:04 +0700 Subject: [PATCH 02/13] feat(arifah): lihat survey, validasi PIN, isi dan submit survey, validasi client-side --- controllers/surveyController.js | 1005 +++++++++++++++++++++++++++++++ routes/survey.js | 13 + views/login.ejs | 42 +- 3 files changed, 1049 insertions(+), 11 deletions(-) create mode 100644 controllers/surveyController.js create mode 100644 routes/survey.js diff --git a/controllers/surveyController.js b/controllers/surveyController.js new file mode 100644 index 00000000..0cb4cd73 --- /dev/null +++ b/controllers/surveyController.js @@ -0,0 +1,1005 @@ +// ============================================================ +// CONTROLLER: RESPONDENT & ADMIN SURVEY (Dikelola oleh Arifah) +// Berisi semua fitur pengisian survey untuk responden dan rekapitulasi statistik untuk admin +// ============================================================ +const db = require('../lib/db'); + +// FITUR 1: Lihat Daftar Survey (Respondent) +// Menampilkan daftar survey aktif, pencarian survey, status "Sudah Diisi", dan pagination +const index = async (req, res, next) => { + try { + const search = req.query.search || ''; + const page = parseInt(req.query.page) || 1; + const limit = 10; + const offset = (page - 1) * limit; + + const [surveys] = await db.query(` + SELECT s.id, s.title, s.description, s.start_date, s.end_date, + s.is_active, s.created_by, s.employee_id, s.created_at, s.updated_at, + CASE WHEN COUNT(sr.id) > 0 THEN 1 ELSE 0 END as sudah_diisi + FROM surveys s + LEFT JOIN survey_invitations si + ON si.survey_id = s.id AND si.email = ? + LEFT JOIN survey_responses sr + ON sr.survey_invitation_id = si.id + WHERE s.is_active = 1 + AND (s.title LIKE ? OR s.description LIKE ?) + GROUP BY s.id, s.title, s.description, s.start_date, s.end_date, + s.is_active, s.created_by, s.employee_id, s.created_at, s.updated_at + LIMIT ? OFFSET ? + `, [req.session.userEmail, `%${search}%`, `%${search}%`, limit, offset]); + + const [[{ total }]] = await db.query(` + SELECT COUNT(*) as total FROM surveys + WHERE is_active = 1 AND (title LIKE ? OR description LIKE ?) + `, [`%${search}%`, `%${search}%`]); + + const totalPages = Math.ceil(total / limit); + const successMsg = req.query.success ? 'Survey berhasil diisi! Terima kasih.' : null; + + res.render('home', { + title: 'Daftar Survey', + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+

Daftar Survey

+

Survey yang tersedia untuk diisi

+
+ + ${successMsg ? ` +
+ โœ… ${successMsg} +
+ ` : ''} + +
+ + + ${search ? 'Reset' : ''} +
+ + ${surveys.length === 0 ? ` +
+
+ Tidak ada survey yang tersedia +
+
+ ` : surveys.map(s => ` +
+
+

${s.title}

+

${s.description || ''}

+
+
+
+ Periode: ${new Date(s.start_date).toLocaleDateString('id-ID')} - ${new Date(s.end_date).toLocaleDateString('id-ID')} +
+ ${s.sudah_diisi ? ` + + Sudah Diisi + + ` : ` + Isi Survey + `} +
+
+ `).join('')} + + ${totalPages > 1 ? ` +
+ ${page > 1 ? `Sebelumnya` : ''} + Halaman ${page} dari ${totalPages} + ${page < totalPages ? `Berikutnya` : ''} +
+ ` : ''} +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 2 (Bagian A): Form Input PIN Survey (Respondent) +// Menampilkan halaman pengisian PIN untuk masuk ke kuesioner +const pinForm = async (req, res, next) => { + try { + const surveyId = req.params.id; + + const [surveys] = await db.query( + 'SELECT * FROM surveys WHERE id = ? AND is_active = 1', + [surveyId] + ); + + if (surveys.length === 0) { + return res.status(404).render('error', { + message: 'Survey tidak ditemukan', + error: { status: 404, stack: '' } + }); + } + + const survey = surveys[0]; + const error = req.query.error || null; + + res.render('home', { + title: `Masukkan PIN - ${survey.title}`, + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+ โ† Kembali +

${survey.title}

+

${survey.description || ''}

+
+ +
+
+

Masukkan PIN

+

Masukkan PIN yang sudah Anda terima untuk mengisi survey ini

+
+
+ ${error ? ` +
+ โš ๏ธ ${error} +
+ ` : ''} +
+
+ + + +
+ +
+ +
+
+
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 2 (Bagian B): Proses Validasi PIN Survey (Respondent) +// Memeriksa kesesuaian PIN dengan survey, email responden, dan status 'is_used' di database +const pinValidate = async (req, res, next) => { + try { + const surveyId = req.params.id; + const { pin } = req.body; + + if (!pin || pin.trim() === '') { + return res.redirect(`/survey/${surveyId}?error=PIN tidak boleh kosong`); + } + + const [invitations] = await db.query(` + SELECT * FROM survey_invitations + WHERE survey_id = ? AND pin = ? AND email = ? AND is_used = 0 + `, [surveyId, pin.trim(), req.session.userEmail]); + + if (invitations.length === 0) { + return res.redirect(`/survey/${surveyId}?error=PIN tidak valid atau sudah digunakan`); + } + + req.session.invitationId = invitations[0].id; + res.redirect(`/survey/${surveyId}/fill`); + } catch (err) { + next(err); + } +}; + +// FITUR 2 (Bagian C): Render Form Pertanyaan Survey (Respondent) +// Menampilkan daftar pertanyaan kuesioner (pilihan ganda tunggal, banyak, atau esai) +const fillForm = async (req, res, next) => { + try { + const surveyId = req.params.id; + + if (!req.session.invitationId) { + return res.redirect(`/survey/${surveyId}`); + } + + const [surveys] = await db.query( + 'SELECT * FROM surveys WHERE id = ? AND is_active = 1', + [surveyId] + ); + + if (surveys.length === 0) { + return res.status(404).render('error', { + message: 'Survey tidak ditemukan', + error: { status: 404, stack: '' } + }); + } + + const survey = surveys[0]; + + const [questions] = await db.query(` + SELECT sq.*, sqa.order + FROM survey_questions sq + JOIN survey_question_assignments sqa ON sq.id = sqa.survey_question_id + WHERE sqa.survey_id = ? AND sq.is_active = 1 + ORDER BY sqa.order ASC + `, [surveyId]); + + for (const q of questions) { + const [options] = await db.query( + 'SELECT * FROM survey_question_options WHERE survey_question_id = ?', + [q.id] + ); + q.options = options; + } + + const questionsHtml = questions.map((q, index) => ` +
+
+

${index + 1}. ${q.question_text}

+ ${q.type === 'multiple_choice' ? '

Pilih satu atau lebih jawaban

' : ''} +
+
+ ${q.type === 'single_choice' ? ` +
+ ${q.options.map(opt => ` + + `).join('')} +
+ ` : q.type === 'multiple_choice' ? ` +
+ ${q.options.map(opt => ` + + `).join('')} +
+ ` : ` + + `} +
+
+ `).join(''); + + res.render('home', { + title: survey.title, + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+ โ† Kembali +

${survey.title}

+

${survey.description || ''}

+
+
+ ${questionsHtml} + +
+ +
+
+ +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 2 (Bagian D): Proses Submit Jawaban Survey (Respondent) +// Validasi di sisi server (wajib diisi) dan menyimpan data jawaban ke database (tabel survey_responses, survey_answers, survey_answer_options) +const submit = async (req, res, next) => { + try { + const surveyId = req.params.id; + const body = req.body; + const invitationId = req.session.invitationId; + + if (!invitationId) { + return res.redirect(`/survey/${surveyId}`); + } + + const [surveys] = await db.query( + 'SELECT * FROM surveys WHERE id = ? AND is_active = 1', + [surveyId] + ); + + if (surveys.length === 0) { + return res.status(404).render('error', { + message: 'Survey tidak ditemukan', + error: { status: 404, stack: '' } + }); + } + + const [questions] = await db.query(` + SELECT sq.* FROM survey_questions sq + JOIN survey_question_assignments sqa ON sq.id = sqa.survey_question_id + WHERE sqa.survey_id = ? AND sq.is_active = 1 + `, [surveyId]); + + const errors = []; + for (const q of questions) { + if (q.type === 'single_choice') { + if (!body[`answer_${q.id}`]) { + errors.push(`Pertanyaan "${q.question_text}" wajib dijawab`); + } + } else if (q.type === 'multiple_choice') { + const answers = body[`answer_${q.id}[]`]; + if (!answers || (Array.isArray(answers) && answers.length === 0)) { + errors.push(`Pertanyaan "${q.question_text}" wajib dijawab`); + } + } else { + if (!body[`answer_text_${q.id}`] || body[`answer_text_${q.id}`].trim() === '') { + errors.push(`Pertanyaan "${q.question_text}" wajib dijawab`); + } + } + } + + if (errors.length > 0) { + return res.status(400).render('home', { + title: 'Jawaban Tidak Lengkap', + user: req.session.userName, + role: req.session.userRole, + body: ` +
+

Jawaban Tidak Lengkap

+
+
+ ${errors.map(e => `

โ€ข ${e}

`).join('')} +
+
+ Kembali ke Survey +
+ ` + }); + } + + const [responseResult] = await db.query(` + INSERT INTO survey_responses (survey_id, survey_invitation_id, submitted_at, created_at, updated_at) + VALUES (?, ?, NOW(), NOW(), NOW()) + `, [surveyId, invitationId]); + + const responseId = responseResult.insertId; + + for (const q of questions) { + if (q.type === 'single_choice') { + const optionId = body[`answer_${q.id}`]; + const [answerResult] = await db.query(` + INSERT INTO survey_answers (survey_response_id, survey_question_id, answer_text, created_at, updated_at) + VALUES (?, ?, '', NOW(), NOW()) + `, [responseId, q.id]); + await db.query(` + INSERT INTO survey_answer_options (survey_answer_id, survey_question_option_id, created_at, updated_at) + VALUES (?, ?, NOW(), NOW()) + `, [answerResult.insertId, optionId]); + + } else if (q.type === 'multiple_choice') { + let optionIds = body[`answer_${q.id}[]`]; + if (!Array.isArray(optionIds)) optionIds = [optionIds]; + const [answerResult] = await db.query(` + INSERT INTO survey_answers (survey_response_id, survey_question_id, answer_text, created_at, updated_at) + VALUES (?, ?, '', NOW(), NOW()) + `, [responseId, q.id]); + for (const optionId of optionIds) { + await db.query(` + INSERT INTO survey_answer_options (survey_answer_id, survey_question_option_id, created_at, updated_at) + VALUES (?, ?, NOW(), NOW()) + `, [answerResult.insertId, optionId]); + } + + } else { + await db.query(` + INSERT INTO survey_answers (survey_response_id, survey_question_id, answer_text, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW()) + `, [responseId, q.id, body[`answer_text_${q.id}`]]); + } + } + + await db.query(` + UPDATE survey_invitations SET is_used = 1, used_at = NOW(), updated_at = NOW() + WHERE id = ? + `, [invitationId]); + + delete req.session.invitationId; + res.redirect('/survey?success=1'); + } catch (err) { + next(err); + } +}; + +// FITUR 3 (Bagian A): List Rekap Jawaban Per Survey (Admin) +// Menampilkan daftar seluruh survey beserta total jumlah responden yang sudah mengisi +const rekap = async (req, res, next) => { + try { + const search = req.query.search || ''; + const page = parseInt(req.query.page) || 1; + const limit = 10; + const offset = (page - 1) * limit; + + const [surveys] = await db.query(` + SELECT s.id, s.title, s.description, s.is_active, s.created_at, + COUNT(DISTINCT sr.id) as total_responses + FROM surveys s + LEFT JOIN survey_invitations si ON si.survey_id = s.id + LEFT JOIN survey_responses sr ON sr.survey_invitation_id = si.id + WHERE s.title LIKE ? OR s.description LIKE ? + GROUP BY s.id, s.title, s.description, s.is_active, s.created_at + ORDER BY s.created_at DESC + LIMIT ? OFFSET ? + `, [`%${search}%`, `%${search}%`, limit, offset]); + + const [[{ total }]] = await db.query(` + SELECT COUNT(*) as total FROM surveys + WHERE title LIKE ? OR description LIKE ? + `, [`%${search}%`, `%${search}%`]); + + const totalPages = Math.ceil(total / limit); + + res.render('home', { + title: 'Rekap Jawaban', + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+

Rekap Jawaban Survey

+

Lihat detail jawaban per survey

+
+ +
+ + + ${search ? 'Reset' : ''} +
+ +
+
+ + + + + + + + + + + ${surveys.length === 0 ? ` + + ` : surveys.map(s => ` + + + + + + + `).join('')} + +
Judul SurveyStatusTotal RespondenAksi
Tidak ada survey
${s.title}${s.is_active ? 'Aktif' : 'Tidak Aktif'}${s.total_responses} + Detail + Export CSV +
+
+
+ + ${totalPages > 1 ? ` +
+ ${page > 1 ? `Sebelumnya` : ''} + Halaman ${page} dari ${totalPages} + ${page < totalPages ? `Berikutnya` : ''} +
+ ` : ''} +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 3 (Bagian B): Detail Responden Per Survey (Admin) +// Menampilkan detail nama, email, dan waktu submit responden di survey tertentu +const rekapDetail = async (req, res, next) => { + try { + const surveyId = req.params.id; + const search = req.query.search || ''; + const page = parseInt(req.query.page) || 1; + const limit = 10; + const offset = (page - 1) * limit; + const errorMsg = req.query.error || null; + + const [surveys] = await db.query('SELECT * FROM surveys WHERE id = ?', [surveyId]); + if (surveys.length === 0) { + return res.status(404).render('error', { message: 'Survey tidak ditemukan', error: { status: 404, stack: '' } }); + } + + const survey = surveys[0]; + + const [responses] = await db.query(` + SELECT sr.id, sr.submitted_at, si.name as responden_name, si.email as responden_email + FROM survey_responses sr + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE si.survey_id = ? AND (si.name LIKE ? OR si.email LIKE ?) + ORDER BY sr.submitted_at DESC + LIMIT ? OFFSET ? + `, [surveyId, `%${search}%`, `%${search}%`, limit, offset]); + + const [[{ total }]] = await db.query(` + SELECT COUNT(*) as total FROM survey_responses sr + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE si.survey_id = ? AND (si.name LIKE ? OR si.email LIKE ?) + `, [surveyId, `%${search}%`, `%${search}%`]); + + const totalPages = Math.ceil(total / limit); + + res.render('home', { + title: `Rekap - ${survey.title}`, + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+ โ† Kembali +

${survey.title}

+

Total ${total} responden

+
+ + ${errorMsg ? ` +
+ โš ๏ธ ${errorMsg} +
+ ` : ''} + +
+ + + ${search ? `Reset` : ''} +
+ +
+
+ + + + + + + + + + ${responses.length === 0 ? ` + + ` : responses.map(r => ` + + + + + + `).join('')} + +
NamaEmailWaktu Pengisian
Belum ada responden
${r.responden_name}${r.responden_email}${new Date(r.submitted_at).toLocaleString('id-ID')}
+
+
+ + ${totalPages > 1 ? ` +
+ ${page > 1 ? `Sebelumnya` : ''} + Halaman ${page} dari ${totalPages} + ${page < totalPages ? `Berikutnya` : ''} +
+ ` : ''} +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 4 (Bagian A): List Survey Untuk Statistik (Admin) +// Menampilkan daftar survey untuk memilih statistik jawaban +const statistik = async (req, res, next) => { + try { + const search = req.query.search || ''; + const page = parseInt(req.query.page) || 1; + const limit = 10; + const offset = (page - 1) * limit; + + const [surveys] = await db.query(` + SELECT s.id, s.title, s.description, COUNT(DISTINCT sr.id) as total_responses + FROM surveys s + LEFT JOIN survey_invitations si ON si.survey_id = s.id + LEFT JOIN survey_responses sr ON sr.survey_invitation_id = si.id + WHERE s.title LIKE ? OR s.description LIKE ? + GROUP BY s.id, s.title, s.description + ORDER BY s.created_at DESC + LIMIT ? OFFSET ? + `, [`%${search}%`, `%${search}%`, limit, offset]); + + const [[{ total }]] = await db.query(` + SELECT COUNT(*) as total FROM surveys + WHERE title LIKE ? OR description LIKE ? + `, [`%${search}%`, `%${search}%`]); + + const totalPages = Math.ceil(total / limit); + + res.render('home', { + title: 'Statistik Survey', + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+

Statistik Survey

+

Pilih survey untuk lihat statistik jawaban

+
+ +
+ + + ${search ? 'Reset' : ''} +
+ +
+ ${surveys.length === 0 ? ` +
+
+ Tidak ada survey yang ditemukan +
+
+ ` : surveys.map(s => ` +
+
+

${s.title}

+

${s.description || ''}

+
+
+ ${s.total_responses} responden + Lihat Statistik +
+
+ `).join('')} +
+ + ${totalPages > 1 ? ` +
+ ${page > 1 ? `Sebelumnya` : ''} + Halaman ${page} dari ${totalPages} + ${page < totalPages ? `Berikutnya` : ''} +
+ ` : ''} +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 4 (Bagian B): Detail Statistik Jawaban (Admin) +// Menampilkan progress bar persentase jawaban pilihan ganda, dan daftar esai untuk short_answer +const statistikDetail = async (req, res, next) => { + try { + const surveyId = req.params.id; + + const [surveys] = await db.query('SELECT * FROM surveys WHERE id = ?', [surveyId]); + if (surveys.length === 0) { + return res.status(404).render('error', { message: 'Survey tidak ditemukan', error: { status: 404, stack: '' } }); + } + + const survey = surveys[0]; + + const [[{ totalResponden }]] = await db.query(` + SELECT COUNT(*) as totalResponden FROM survey_responses sr + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE si.survey_id = ? + `, [surveyId]); + + const [questions] = await db.query(` + SELECT sq.* FROM survey_questions sq + JOIN survey_question_assignments sqa ON sq.id = sqa.survey_question_id + WHERE sqa.survey_id = ? + ORDER BY sqa.order ASC + `, [surveyId]); + + for (const q of questions) { + if (q.type !== 'short_answer') { + const [options] = await db.query(` + SELECT soo.id, soo.option_text, COUNT(sao.id) as jumlah_dipilih + FROM survey_question_options soo + LEFT JOIN survey_answer_options sao ON soo.id = sao.survey_question_option_id + LEFT JOIN survey_answers sa ON sao.survey_answer_id = sa.id + LEFT JOIN survey_responses sr ON sa.survey_response_id = sr.id + LEFT JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE soo.survey_question_id = ? AND (si.survey_id = ? OR si.survey_id IS NULL) + GROUP BY soo.id, soo.option_text + `, [q.id, surveyId]); + q.options = options; + } else { + // Ambil jawaban teks bebas untuk ditampilkan + const [jawaban] = await db.query(` + SELECT sa.answer_text FROM survey_answers sa + JOIN survey_responses sr ON sa.survey_response_id = sr.id + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE sa.survey_question_id = ? AND si.survey_id = ? + `, [q.id, surveyId]); + q.jawaban_teks = jawaban; + } + } + + const statistikHtml = questions.map((q, index) => ` +
+
+

${index + 1}. ${q.question_text}

+
+
+ ${q.type !== 'short_answer' ? ` +
+ ${q.options.map(opt => { + const persen = totalResponden > 0 ? Math.round((opt.jumlah_dipilih / totalResponden) * 100) : 0; + return ` +
+
+ ${opt.option_text} + ${opt.jumlah_dipilih} (${persen}%) +
+
+
+
+
+ `; + }).join('')} +
+ ` : q.jawaban_teks.length === 0 ? ` +

Belum ada jawaban

+ ` : ` +
+ ${q.jawaban_teks.map(j => ` +
+ ${j.answer_text} +
+ `).join('')} +
+ `} +
+
+ `).join(''); + + res.render('home', { + title: `Statistik - ${survey.title}`, + user: req.session.userName, + role: req.session.userRole, + body: ` +
+
+ โ† Kembali +

${survey.title}

+

Total ${totalResponden} responden

+
+ ${statistikHtml} +
+ ` + }); + } catch (err) { + next(err); + } +}; + +// FITUR 5: Export CSV Data Mentah (Admin) +// Mengunduh seluruh respons mentah ke file format CSV (Excel-friendly BOM UTF-8) +const exportCsv = async (req, res, next) => { + try { + const surveyId = req.params.id; + + const [surveys] = await db.query('SELECT * FROM surveys WHERE id = ?', [surveyId]); + if (surveys.length === 0) { + return res.status(404).render('error', { message: 'Survey tidak ditemukan', error: { status: 404, stack: '' } }); + } + + const survey = surveys[0]; + + // Cek apakah ada responden dulu + // Kenapa: sesuai poin 2b instruksi dosen โ€” error handling informatif di client + const [[{ totalResponden }]] = await db.query(` + SELECT COUNT(*) as totalResponden FROM survey_responses sr + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE si.survey_id = ? + `, [surveyId]); + + if (totalResponden === 0) { + return res.redirect(`/admin/survey/rekap/${surveyId}?error=Belum ada responden, data tidak dapat diekspor`); + } + + const [questions] = await db.query(` + SELECT sq.* FROM survey_questions sq + JOIN survey_question_assignments sqa ON sq.id = sqa.survey_question_id + WHERE sqa.survey_id = ? + ORDER BY sqa.order ASC + `, [surveyId]); + + const [responses] = await db.query(` + SELECT sr.id as response_id, si.name, si.email, sr.submitted_at + FROM survey_responses sr + JOIN survey_invitations si ON sr.survey_invitation_id = si.id + WHERE si.survey_id = ? + ORDER BY sr.submitted_at DESC + `, [surveyId]); + + const headers = ['Nama', 'Email', 'Waktu Submit', ...questions.map(q => q.question_text)]; + const rows = []; + + for (const resp of responses) { + const row = [resp.name, resp.email, new Date(resp.submitted_at).toLocaleString('id-ID')]; + for (const q of questions) { + if (q.type !== 'short_answer') { + const [ans] = await db.query(` + SELECT GROUP_CONCAT(soo.option_text SEPARATOR ', ') as jawaban + FROM survey_answer_options sao + JOIN survey_question_options soo ON sao.survey_question_option_id = soo.id + JOIN survey_answers sa ON sao.survey_answer_id = sa.id + WHERE sa.survey_response_id = ? AND sa.survey_question_id = ? + `, [resp.response_id, q.id]); + row.push(ans[0].jawaban || '-'); + } else { + const [ans] = await db.query(` + SELECT answer_text FROM survey_answers + WHERE survey_response_id = ? AND survey_question_id = ? + `, [resp.response_id, q.id]); + row.push(ans.length > 0 ? ans[0].answer_text : '-'); + } + } + rows.push(row); + } + + const csvContent = [headers, ...rows] + .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="survey_${surveyId}_${Date.now()}.csv"`); + res.send('\uFEFF' + csvContent); + } catch (err) { + next(err); + } +}; + +// FITUR 6: REST API GET List Survey + Pertanyaan (REST API) +// Mengembalikan data JSON berupa kuesioner aktif lengkap dengan opsi jawabannya +const apiSurveys = async (req, res, next) => { + try { + const [surveys] = await db.query( + 'SELECT id, title, description, start_date, end_date FROM surveys WHERE is_active = 1' + ); + + for (const s of surveys) { + const [questions] = await db.query(` + SELECT sq.id, sq.question_text, sq.type + FROM survey_questions sq + JOIN survey_question_assignments sqa ON sq.id = sqa.survey_question_id + WHERE sqa.survey_id = ? + ORDER BY sqa.order ASC + `, [s.id]); + + for (const q of questions) { + if (q.type !== 'short_answer') { + const [options] = await db.query( + 'SELECT id, option_text, weight FROM survey_question_options WHERE survey_question_id = ?', + [q.id] + ); + q.options = options; + } + } + s.questions = questions; + } + + res.json({ success: true, data: surveys, total: surveys.length }); + } catch (err) { + next(err); + } +}; + +module.exports = { + index, + pinForm, + pinValidate, + fillForm, + submit, + rekap, + rekapDetail, + statistik, + statistikDetail, + exportCsv, + apiSurveys +}; \ No newline at end of file diff --git a/routes/survey.js b/routes/survey.js new file mode 100644 index 00000000..ec50479a --- /dev/null +++ b/routes/survey.js @@ -0,0 +1,13 @@ +var express = require('express'); +var router = express.Router(); +const { isAuthenticated } = require('../middlewares/auth'); +const { checkRole } = require('../middlewares/acl'); +const surveyController = require('../controllers/surveyController'); + +router.get('/', isAuthenticated, checkRole('respondent'), surveyController.index); +router.get('/:id', isAuthenticated, checkRole('respondent'), surveyController.pinForm); +router.post('/:id/pin', isAuthenticated, checkRole('respondent'), surveyController.pinValidate); +router.get('/:id/fill', isAuthenticated, checkRole('respondent'), surveyController.fillForm); +router.post('/:id/submit', isAuthenticated, checkRole('respondent'), surveyController.submit); + +module.exports = router; \ No newline at end of file diff --git a/views/login.ejs b/views/login.ejs index 8ffae27d..3b860c68 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -121,7 +121,7 @@ class="flex flex-col gap-6 rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden p-0" >
-
+
-

- Don't have an account? Sign up -

+ + -
+
-
-
-

- All of the shadcn/ui magic, none of the React -

-

- A components library built with Tailwind CSS that works with any - web stack. -

-
- -
- -
-
-
-
-

Team Members

-

Invite your team members to collaborate.

-
-
-
    -
  • - Sofia Davis -
    -

    - Sofia Davis -

    -

    - m@example.com -

    -
    - -
    - - - -
    -
  • -
  • - Jackson Lee -
    -

    - Jackson Lee -

    -

    - p@example.com -

    -
    - -
    - - - -
    -
  • -
  • - Isabella Nguyen -
    -

    - Isabella Nguyen -

    -

    - i@example.com -

    -
    - -
    - - - -
    -
  • -
-
-
- -
-
-

Cookie Settings

-

Manage your cookie settings here.

-
-
- - - -
-
- -
-
- -
-
-

Payment Method

-

Add a new payment method to your account.

-
-
-
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- -
-
-
-
- Sofia Davis -
-

- Sofia Davis -

-

m@example.com

-
- -
-
-
- Hi, how can I help you today? -
-
- Hey, I'm having trouble with my account. -
-
- What seems to be the problem? -
-
- I can't log in. -
-
-
- - -
-
-
- -
-
-

Create an account

-

Enter your email below to create your account

-
-
-
- - -
-
-
- -
-
- Or continue with -
-
-
-
- - -
-
-
- - Forgot your password? -
- -
- -
-
-
- -
-
-

Report an issue

-

What area are you having problems with?

-
-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
+ + <%- body %>
- + \ No newline at end of file From bd1830a7f73a8560b479303d70fbcb412bf9dba2 Mon Sep 17 00:00:00 2001 From: Aqila Higena Taufik Date: Tue, 23 Jun 2026 14:14:09 +0700 Subject: [PATCH 07/13] update indexController, acl, home --- controllers/indexController.js | 48 +- middlewares/acl.js | 77 +- views/home.ejs | 1890 +++----------------------------- 3 files changed, 215 insertions(+), 1800 deletions(-) diff --git a/controllers/indexController.js b/controllers/indexController.js index 5ea918c1..c2d9dcf0 100644 --- a/controllers/indexController.js +++ b/controllers/indexController.js @@ -6,7 +6,17 @@ const index = (req, res) => { }; const home = (req, res) => { - res.render("home", { title: "Home", user: req.session.username }); + res.render("home", { + title: "Home", + user: req.session.userName, + role: req.session.userRole, + body: ` +
+

Dashboard

+

Selamat datang, ${req.session.userName}

+
+ ` + }); }; const loginPage = (req, res) => { @@ -20,14 +30,15 @@ const login = async (req, res, next) => { const { username, password } = req.body; try { - const [rows] = await db.query("SELECT * FROM users WHERE username = ?", [ - username, - ]); + const [rows] = await db.query( + "SELECT * FROM users WHERE email = ?", + [username] + ); if (rows.length === 0) { return res.render("login", { title: "Login", - error: "Invalid username or password", + error: "Username atau password salah", }); } @@ -37,15 +48,32 @@ const login = async (req, res, next) => { if (!isMatch) { return res.render("login", { title: "Login", - error: "Invalid username or password", + error: "Username atau password salah", }); } - // Set session + const [roleRows] = await db.query(` + SELECT r.name + FROM roles r + JOIN model_has_roles mhr ON r.id = mhr.role_id + WHERE mhr.model_id = ? + AND mhr.model_type = 'App\\\\Models\\\\User' + LIMIT 1 + `, [user.id]); + + const userRole = roleRows.length > 0 ? roleRows[0].name : null; + req.session.userId = user.id; - req.session.username = user.username; + req.session.userName = user.name; + req.session.userEmail = user.email; + req.session.userRole = userRole; + + if (userRole === 'admin') { + return res.redirect("/admin/survey/rekap"); + } else { + return res.redirect("/survey"); + } - res.redirect("/home"); } catch (err) { next(err); } @@ -66,4 +94,4 @@ module.exports = { loginPage, login, logout -}; +}; \ No newline at end of file diff --git a/middlewares/acl.js b/middlewares/acl.js index 1019f567..a337eeb8 100644 --- a/middlewares/acl.js +++ b/middlewares/acl.js @@ -1,48 +1,66 @@ const db = require("../lib/db"); -/** - * ACL Middleware to check if a user has the required permission(s). - * - * @param {string|string[]} requiredPermissions - A single permission or an array of permissions. - * If an array is provided, the user must have at least one of the permissions. - * - * Database Schema Requirements: - * - * 1. roles: id, name - * 2. permissions: id, name - * 3. role_has_permissions: role_id, permission_id - * 4. user_has_roles: user_id, role_id - */ - const checkPermission = (requiredPermissions) => { return async (req, res, next) => { if (!req.session.userId) { - return res.status(401).json({ message: "Unauthorized" }); + return res.redirect("/login"); } - const permissionsArray = Array.isArray(requiredPermissions) - ? requiredPermissions + const permissionsArray = Array.isArray(requiredPermissions) + ? requiredPermissions : [requiredPermissions]; try { - // Query to check if the user has a role that contains any of the required permissions - const query = ` - SELECT DISTINCT p.name + const [rows] = await db.query(` + SELECT DISTINCT p.name FROM permissions p JOIN role_has_permissions rhp ON p.id = rhp.permission_id - JOIN user_has_roles uhr ON rhp.role_id = uhr.role_id - WHERE uhr.user_id = ? AND p.name IN (?) - `; + JOIN model_has_roles mhr ON rhp.role_id = mhr.role_id + WHERE mhr.model_id = ? + AND mhr.model_type = 'App\\\\Models\\\\User' + AND p.name IN (?) + `, [req.session.userId, permissionsArray]); + + if (rows.length > 0) { + return next(); + } + + return res.status(403).render("error", { + message: "Forbidden: Anda tidak punya akses ke halaman ini.", + error: { status: 403, stack: "" } + }); + } catch (err) { + next(err); + } + }; +}; + +const checkRole = (requiredRoles) => { + return async (req, res, next) => { + if (!req.session.userId) { + return res.redirect("/login"); + } - const [rows] = await db.query(query, [req.session.userId, permissionsArray]); + const rolesArray = Array.isArray(requiredRoles) + ? requiredRoles + : [requiredRoles]; + + try { + const [rows] = await db.query(` + SELECT r.name + FROM roles r + JOIN model_has_roles mhr ON r.id = mhr.role_id + WHERE mhr.model_id = ? + AND mhr.model_type = 'App\\\\Models\\\\User' + AND r.name IN (?) + `, [req.session.userId, rolesArray]); if (rows.length > 0) { return next(); } - // If no matching permission found, return Forbidden - res.status(403).render("error", { - message: "Forbidden: You do not have permission to access this resource.", + return res.status(403).render("error", { + message: "Forbidden: Anda tidak punya akses ke halaman ini.", error: { status: 403, stack: "" } }); } catch (err) { @@ -52,5 +70,6 @@ const checkPermission = (requiredPermissions) => { }; module.exports = { - checkPermission -}; + checkPermission, + checkRole +}; \ No newline at end of file diff --git a/views/home.ejs b/views/home.ejs index b0f86047..67d6b20d 100644 --- a/views/home.ejs +++ b/views/home.ejs @@ -5,117 +5,38 @@ (() => { try { const stored = localStorage.getItem("themeMode"); - if ( - stored - ? stored === "dark" - : matchMedia("(prefers-color-scheme: dark)").matches - ) { + if (stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches) { document.documentElement.classList.add("dark"); } } catch (_) {} - const apply = (dark) => { document.documentElement.classList.toggle("dark", dark); - try { - localStorage.setItem("themeMode", dark ? "dark" : "light"); - } catch (_) {} + try { localStorage.setItem("themeMode", dark ? "dark" : "light"); } catch (_) {} }; - document.addEventListener("basecoat:theme", (event) => { const mode = event.detail?.mode; - apply( - mode === "dark" - ? true - : mode === "light" - ? false - : !document.documentElement.classList.contains("dark"), - ); + apply(mode === "dark" ? true : mode === "light" ? false : !document.documentElement.classList.contains("dark")); }); })(); - - - - - Basecoat - - - - + <%= title %> - FacultyWare - - - - + - - - - - - - - - + + - - - - - - -
-
+
-
-
-

- All of the shadcn/ui magic, none of the React -

-

- A components library built with Tailwind CSS that works with any - web stack. -

-
- -
- -
-
-
-
-

Team Members

-

Invite your team members to collaborate.

-
-
-
    -
  • - Sofia Davis -
    -

    - Sofia Davis -

    -

    - m@example.com -

    -
    - -
    - - - -
    -
  • -
  • - Jackson Lee -
    -

    - Jackson Lee -

    -

    - p@example.com -

    -
    - -
    - - - -
    -
  • -
  • - Isabella Nguyen -
    -

    - Isabella Nguyen -

    -

    - i@example.com -

    -
    - -
    - - - -
    -
  • -
-
-
- -
-
-

Cookie Settings

-

Manage your cookie settings here.

-
-
- - - -
-
- -
-
- -
-
-

Payment Method

-

Add a new payment method to your account.

-
-
-
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- -
-
-
-
- Sofia Davis -
-

- Sofia Davis -

-

m@example.com

-
- -
-
-
- Hi, how can I help you today? -
-
- Hey, I'm having trouble with my account. -
-
- What seems to be the problem? -
-
- I can't log in. -
-
-
- - -
-
-
- -
-
-

Create an account

-

Enter your email below to create your account

-
-
-
- - -
-
-
- -
-
- Or continue with -
-
-
-
- - -
-
-
- - Forgot your password? -
- -
- -
-
-
- -
-
-

Report an issue

-

What area are you having problems with?

-
-
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
-
-
-
+ + <%- body %>
- + \ No newline at end of file From 92f34f3ca1f4f54f6d4ed8e9a8239f402cefa347 Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Tue, 23 Jun 2026 19:54:49 +0700 Subject: [PATCH 08/13] test: tambah playwright test suite --- playwright.config.js | 29 +++++++++ tests/admin.spec.js | 148 ++++++++++++++++++++++++++++++++++++++++++ tests/auth.spec.js | 75 +++++++++++++++++++++ tests/global-setup.js | 35 ++++++++++ tests/survey.spec.js | 77 ++++++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 playwright.config.js create mode 100644 tests/admin.spec.js create mode 100644 tests/auth.spec.js create mode 100644 tests/global-setup.js create mode 100644 tests/survey.spec.js diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..d9d4a99b --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,29 @@ +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + globalSetup: './tests/global-setup.js', + fullyParallel: false, + workers: 1, + retries: 1, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: true, + }, +}); \ No newline at end of file diff --git a/tests/admin.spec.js b/tests/admin.spec.js new file mode 100644 index 00000000..ac4af9c5 --- /dev/null +++ b/tests/admin.spec.js @@ -0,0 +1,148 @@ +const { test, expect } = require('@playwright/test'); + +async function loginAsAdmin(page) { + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin@fti.ac.id'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/admin/survey/rekap'); +} + +test.describe('Fitur Admin โ€” Survey', () => { + + // REKAP + test('halaman rekap tampil daftar survey', async ({ page }) => { + await loginAsAdmin(page); + await expect(page.locator('h1')).toContainText('Rekap Jawaban Survey'); + await expect(page.locator('table')).toBeVisible(); + }); + + test('search rekap berfungsi', async ({ page }) => { + await loginAsAdmin(page); + await page.fill('input[name="search"]', 'kepuasan'); + await page.click('button[type="submit"]'); + await expect(page.locator('body')).toContainText('Survey Kepuasan Layanan Akademik'); + }); + + test('detail rekap per survey tampil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/survey/rekap/1'); + await expect(page.locator('h1')).toContainText('Survey Kepuasan Layanan Akademik'); + await expect(page.locator('table')).toBeVisible(); + }); + + // STATISTIK + test('halaman statistik tampil daftar survey', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/survey/statistik'); + await expect(page.locator('h1')).toContainText('Statistik Survey'); + await expect(page.locator('.card').first()).toBeVisible(); + }); + + test('detail statistik per survey tampil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/survey/statistik/1'); + await expect(page.locator('h1')).toContainText('Survey Kepuasan Layanan Akademik'); + await expect(page.locator('body')).toContainText('Bagaimana penilaian Anda'); + }); + + // EXPORT CSV + test('export CSV error kalau belum ada data', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/survey/export/3'); + await expect(page.locator('body')).toContainText('Belum ada responden'); + }); + + test('export CSV berhasil kalau ada data', async ({ page }) => { + await loginAsAdmin(page); + const response = await page.request.get('http://localhost:3000/admin/survey/export/1'); + expect([200, 302]).toContain(response.status()); + }); + + // DASHBOARD AQILA + test('dashboard responden tampil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden'); + await expect(page.locator('h1')).toContainText('Dashboard Survey'); + await expect(page.locator('table')).toBeVisible(); + }); + + test('search dashboard berfungsi', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden'); + await page.fill('input[name="search"]', 'kepuasan'); + await page.click('button[type="submit"]'); + await expect(page.locator('body')).toContainText('Survey Kepuasan Layanan Akademik'); + }); + + test('toggle nonaktifkan survey berhasil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden'); + const row = page.locator('tr:has-text("Survey Evaluasi Fasilitas Kampus")'); + await expect(row).toContainText('Aktif'); + await row.locator('button:has-text("Nonaktifkan")').click(); + await expect( + page.locator('tr:has-text("Survey Evaluasi Fasilitas Kampus")') + ).toContainText('Tidak Aktif'); + }); + + test('toggle aktifkan survey berhasil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden'); + const row = page.locator('tr:has-text("Survey Draft Belum Aktif")'); + await expect(row).toContainText('Tidak Aktif'); + await row.locator('button:has-text("Aktifkan")').click(); + await expect( + page.locator('tr:has-text("Survey Draft Belum Aktif")') + ).toContainText('Aktif'); + }); + + test('daftar responden per survey tampil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden/1'); + await expect(page.locator('h1')).toContainText('Survey Kepuasan Layanan Akademik'); + await expect(page.locator('table')).toBeVisible(); + }); + + test('riwayat pengisian tampil', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden/1/riwayat'); + await expect(page.locator('h1')).toContainText('Riwayat Pengisian'); + await expect(page.locator('table')).toBeVisible(); + }); + + test('PDF error kalau belum ada responden', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/admin/responden/3/pdf'); + await expect(page.locator('body')).toContainText('belum ada responden'); + }); + + // REST API + test('REST API GET surveys return JSON', async ({ page }) => { + await loginAsAdmin(page); + const response = await page.request.get('http://localhost:3000/api/surveys'); + expect(response.status()).toBe(200); + const json = await response.json(); + expect(json.success).toBe(true); + expect(Array.isArray(json.data)).toBe(true); + }); + + test('REST API GET rekap return JSON', async ({ page }) => { + await loginAsAdmin(page); + const response = await page.request.get('http://localhost:3000/api/surveys/1/rekap'); + expect(response.status()).toBe(200); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.data).toHaveProperty('survey'); + expect(json.data).toHaveProperty('total_responden'); + }); + + test('REST API tidak bisa diakses tanpa login', async ({ page }) => { + const context = await page.context().browser().newContext(); + const newPage = await context.newPage(); + await newPage.goto('http://localhost:3000/api/surveys'); + await expect(newPage).toHaveURL(/login/); + await context.close(); + }); + +}); \ No newline at end of file diff --git a/tests/auth.spec.js b/tests/auth.spec.js new file mode 100644 index 00000000..ba380a4c --- /dev/null +++ b/tests/auth.spec.js @@ -0,0 +1,75 @@ +const { test, expect } = require('@playwright/test'); + +test.describe('Authentication & ACL', () => { + + test('login admin berhasil', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin@fti.ac.id'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/admin/survey/rekap'); + await expect(page.locator('h1')).toContainText('Rekap Jawaban Survey'); + }); + + test('login respondent berhasil', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'rifaqila@fti.ac.id'); + await page.fill('input[name="password"]', 'user123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/survey'); + await expect(page.locator('h1')).toContainText('Daftar Survey'); + }); + + test('login gagal password salah', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin@fti.ac.id'); + await page.fill('input[name="password"]', 'passwordsalah'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/login'); + await expect(page.locator('body')).toContainText('Username atau password salah'); + }); + + test('login gagal email tidak ada', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'tidakada@fti.ac.id'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/login'); + await expect(page.locator('body')).toContainText('Username atau password salah'); + }); + + test('respondent tidak bisa akses halaman admin', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'rifaqila@fti.ac.id'); + await page.fill('input[name="password"]', 'user123'); + await page.click('button[type="submit"]'); + await page.goto('/admin/survey/rekap'); + await expect(page.locator('body')).toContainText('Forbidden'); + }); + + test('admin tidak bisa akses halaman respondent', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin@fti.ac.id'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await page.goto('/survey'); + await expect(page.locator('body')).toContainText('Forbidden'); + }); + + test('user tidak login tidak bisa akses halaman manapun', async ({ page }) => { + await page.goto('/survey'); + await expect(page).toHaveURL('/login'); + }); + + test('logout berhasil', async ({ page }) => { + await page.goto('/login'); + await page.fill('input[name="username"]', 'admin@fti.ac.id'); + await page.fill('input[name="password"]', 'admin123'); + await page.click('button[type="submit"]'); + await page.goto('/logout'); + await expect(page).toHaveURL('/login'); + await page.goto('/admin/survey/rekap'); + await expect(page).toHaveURL('/login'); + }); + +}); \ No newline at end of file diff --git a/tests/global-setup.js b/tests/global-setup.js new file mode 100644 index 00000000..17678df2 --- /dev/null +++ b/tests/global-setup.js @@ -0,0 +1,35 @@ +// ============================================================ +// GLOBAL SETUP: Reset database ke kondisi awal sebelum test +// Dipanggil otomatis oleh Playwright sebelum semua test jalan +// ============================================================ +const mysql = require('mysql2/promise'); +require('dotenv').config(); + +async function globalSetup() { + const db = await mysql.createConnection({ + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'facultyware', + }); + + console.log('๐Ÿ”„ Resetting database to initial state...'); + + // Reset status survey + await db.query('UPDATE surveys SET is_active=1 WHERE id IN (1,2)'); + await db.query('UPDATE surveys SET is_active=0 WHERE id=3'); + + // Reset PIN invitations supaya bisa dipakai ulang + await db.query('UPDATE survey_invitations SET is_used=0, used_at=NULL WHERE id IN (1,2)'); + + // Hapus responses dari test sebelumnya + await db.query('DELETE FROM survey_answer_options WHERE survey_answer_id IN (SELECT id FROM survey_answers WHERE survey_response_id IN (SELECT id FROM survey_responses WHERE survey_id IN (1,2)))'); + await db.query('DELETE FROM survey_answers WHERE survey_response_id IN (SELECT id FROM survey_responses WHERE survey_id IN (1,2))'); + await db.query('DELETE FROM survey_responses WHERE survey_id IN (1,2)'); + + console.log('โœ… Database reset complete'); + + await db.end(); +} + +module.exports = globalSetup; \ No newline at end of file diff --git a/tests/survey.spec.js b/tests/survey.spec.js new file mode 100644 index 00000000..ac2711d0 --- /dev/null +++ b/tests/survey.spec.js @@ -0,0 +1,77 @@ +const { test, expect } = require('@playwright/test'); + +async function loginAsRespondent(page) { + await page.goto('/login'); + await page.fill('input[name="username"]', 'rifaqila@fti.ac.id'); + await page.fill('input[name="password"]', 'user123'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/survey'); +} + +test.describe('Fitur Respondent โ€” Survey', () => { + + test('list survey aktif tampil', async ({ page }) => { + await loginAsRespondent(page); + await expect(page.locator('h1')).toContainText('Daftar Survey'); + await expect(page.locator('.card').first()).toBeVisible(); + }); + + test('search survey berfungsi', async ({ page }) => { + await loginAsRespondent(page); + await page.fill('input[name="search"]', 'kepuasan'); + await page.click('button[type="submit"]'); + await expect(page.locator('body')).toContainText('Survey Kepuasan Layanan Akademik'); + await expect(page.locator('body')).not.toContainText('Survey Evaluasi Fasilitas Kampus'); + }); + + test('tombol reset search berfungsi', async ({ page }) => { + await loginAsRespondent(page); + await page.fill('input[name="search"]', 'kepuasan'); + await page.click('button[type="submit"]'); + await page.click('a:has-text("Reset")'); + await expect(page).toHaveURL('/survey'); + }); + + test('form PIN muncul setelah klik isi survey', async ({ page }) => { + await loginAsRespondent(page); + await page.goto('/survey/1'); + await expect(page.locator('body')).toContainText('Masukkan PIN'); + }); + + test('PIN salah menampilkan pesan error', async ({ page }) => { + await loginAsRespondent(page); + await page.goto('/survey/1'); + await page.fill('input[name="pin"]', 'PINSALAH'); + await page.click('button[type="submit"]'); + await expect(page.locator('body')).toContainText('PIN tidak valid atau sudah digunakan'); + }); + +test('PIN kosong input required ada', async ({ page }) => { + await loginAsRespondent(page); + await page.goto('/survey/1'); + // Validasi via JavaScript (client-side), bukan atribut required + await page.click('button[type="submit"]'); + await expect(page.locator('#pinError')).toBeVisible(); +}); + + test('PIN benar masuk form survey', async ({ page }) => { + await loginAsRespondent(page); + await page.goto('/survey/1'); + await page.fill('input[name="pin"]', 'PIN001'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/survey/1/fill'); + await expect(page.locator('body')).toContainText('Bagaimana penilaian Anda'); + }); + + test('submit survey tidak lengkap menampilkan validasi error', async ({ page }) => { + await loginAsRespondent(page); + await page.goto('/survey/1'); + await page.fill('input[name="pin"]', 'PIN001'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('/survey/1/fill'); + // Submit tanpa isi apapun + await page.click('button:has-text("Kirim Jawaban")'); + await expect(page.locator('body')).toContainText('wajib dijawab'); + }); + +}); \ No newline at end of file From 5086c26aca87ef065fbafa7f47fc444986ec748d Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Tue, 23 Jun 2026 20:09:18 +0700 Subject: [PATCH 09/13] chore: railway config dan DB port support --- lib/db.js | 3 ++- railway.json | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 railway.json diff --git a/lib/db.js b/lib/db.js index 76a7d5b8..25edc7cb 100644 --- a/lib/db.js +++ b/lib/db.js @@ -3,6 +3,7 @@ require('dotenv').config(); const pool = mysql.createPool({ host: process.env.DB_HOST, + port: process.env.DB_PORT || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, @@ -11,4 +12,4 @@ const pool = mysql.createPool({ queueLimit: 0 }); -module.exports = pool.promise(); +module.exports = pool.promise(); \ No newline at end of file diff --git a/railway.json b/railway.json new file mode 100644 index 00000000..f13a125b --- /dev/null +++ b/railway.json @@ -0,0 +1,9 @@ +{ + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "npm start", + "restartPolicyType": "ON_FAILURE" + } +} \ No newline at end of file From 19c231fba9497a8c536ced8d3edf09100f71df66 Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Wed, 24 Jun 2026 10:40:43 +0700 Subject: [PATCH 10/13] chore: update railway config pakai versi Aqila --- railway.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/railway.json b/railway.json index f13a125b..1bb04d84 100644 --- a/railway.json +++ b/railway.json @@ -1,9 +1,11 @@ { + "$schema": "https://railway.app/railway.schema.json", "build": { "builder": "NIXPACKS" }, "deploy": { "startCommand": "npm start", - "restartPolicyType": "ON_FAILURE" + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 } } \ No newline at end of file From 2747845f9f1362b74a1c0e036ea42b2328e9f51a Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Wed, 24 Jun 2026 10:46:37 +0700 Subject: [PATCH 11/13] fix: update title dan meta login page --- views/login.ejs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/views/login.ejs b/views/login.ejs index 3b860c68..09bed3b0 100644 --- a/views/login.ejs +++ b/views/login.ejs @@ -53,12 +53,12 @@ - Basecoat + Login - + Date: Wed, 24 Jun 2026 11:33:42 +0700 Subject: [PATCH 12/13] fix: redirect root ke halaman login --- controllers/indexController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/indexController.js b/controllers/indexController.js index c2d9dcf0..95ba0e94 100644 --- a/controllers/indexController.js +++ b/controllers/indexController.js @@ -2,7 +2,7 @@ const bcrypt = require("bcryptjs"); const db = require("../lib/db"); const index = (req, res) => { - res.render("index", { title: "Express" }); + res.redirect("/login"); }; const home = (req, res) => { From 3fab6b205b13acae4adce6682db5b6e3908fb45c Mon Sep 17 00:00:00 2001 From: Arifah Huwaina Azre Date: Wed, 24 Jun 2026 12:52:25 +0700 Subject: [PATCH 13/13] feat: add database auto setup script --- package.json | 3 +- scripts/setup_db.js | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 scripts/setup_db.js diff --git a/package.json b/package.json index 00d26d30..7256d3ed 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "start": "node ./bin/www", - "dev": "nodemon ./bin/www" + "dev": "nodemon ./bin/www", + "db:setup": "node ./scripts/setup_db.js" }, "dependencies": { "bcryptjs": "^3.0.3", diff --git a/scripts/setup_db.js b/scripts/setup_db.js new file mode 100644 index 00000000..594f2fc4 --- /dev/null +++ b/scripts/setup_db.js @@ -0,0 +1,92 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const mysql = require('mysql2/promise'); +require('dotenv').config(); + +const sqlPath = "D:\\arifah\\SMT 4\\REAL PWEB A\\db_tb_pweb_v2.sql"; + +async function setup() { + console.log('๐Ÿ Starting database setup...'); + console.log(`Connection Config:`); + console.log(`- Host: ${process.env.DB_HOST}`); + console.log(`- Port: ${process.env.DB_PORT || 3306}`); + console.log(`- User: ${process.env.DB_USER}`); + console.log(`- Database: ${process.env.DB_NAME}`); + console.log(`-----------------------------------`); + + let connection; + try { + // 1. Read and parse SQL schema file + if (!fs.existsSync(sqlPath)) { + throw new Error(`SQL file not found at: ${sqlPath}`); + } + + console.log('๐Ÿ“– Reading SQL schema file...'); + const buffer = fs.readFileSync(sqlPath); + let sqlText; + if (buffer[0] === 0xff && buffer[1] === 0xfe) { + sqlText = buffer.toString('utf16le'); + } else { + sqlText = buffer.toString('utf8'); + } + + // Strip UTF BOM if present + sqlText = sqlText.replace(/^\ufeff/, ''); + + const dbName = process.env.DB_NAME || 'facultyware'; + console.log(`๐Ÿงน Cleaning SQL schema and substituting database name with "${dbName}"...`); + + // Replace schema references + sqlText = sqlText.replace(/CREATE SCHEMA IF NOT EXISTS `facultyware`/g, `CREATE SCHEMA IF NOT EXISTS \`${dbName}\``); + sqlText = sqlText.replace(/USE `facultyware`/g, `USE \`${dbName}\``); + sqlText = sqlText.replace(/`facultyware`\./g, `\`${dbName}\`.`); + + // Split SQL by semicolon followed by newline + const statements = sqlText + .split(/;\s*[\r\n]+/) + .map(s => s.trim()) + .filter(s => s.length > 0); + + console.log(`๐Ÿ“ฆ Found ${statements.length} SQL statements to execute.`); + + // 2. Connect to MySQL Server (first without database to ensure we can create it if it doesn't exist) + console.log('๐Ÿ”Œ Connecting to MySQL server...'); + connection = await mysql.createConnection({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '3306'), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + multipleStatements: true + }); + + console.log('๐Ÿš€ Executing SQL schema statements...'); + let successCount = 0; + for (let i = 0; i < statements.length; i++) { + const stmt = statements[i]; + try { + await connection.query(stmt); + successCount++; + } catch (err) { + // Log query error but continue (some drop tables might fail if they don't exist) + console.warn(`โš ๏ธ Warning in statement ${i + 1}: ${err.message}`); + } + } + console.log(`โœ… Executed ${successCount}/${statements.length} statements successfully.`); + await connection.end(); + + // 3. Run the Seeder + console.log('\n๐ŸŒฑ Running seeder...'); + execSync('node scripts/seed.js', { stdio: 'inherit' }); + + console.log('\n๐ŸŽ‰ Database setup and seeding completed successfully!'); + } catch (err) { + console.error('\nโŒ Database setup failed:', err.message); + if (connection) { + try { await connection.end(); } catch (_) {} + } + process.exit(1); + } +} + +setup();